Repository navigation
Expand file tree
/
Copy pathpractice_ui.lua
More file actions
7982 lines (7578 loc) · 319 KB
/
Copy pathpractice_ui.lua
File metadata and controls
7982 lines (7578 loc) · 319 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: the playable UI for the pure st.practice simulation engine (port phase 2).
--
-- Ported from the archived build's practice UI (docs/swedge/SwedgeTimer_Forever/practice.lua):
-- * control panel 558-914 (panel rows in the reference screenshot's order:
-- title, speed/GCD + rotation lines, two icon rows,
-- the bind hint and the (Reset) link, the
-- Bloodlust/Haste-pot pair, the rotation selector,
-- Start Fight and the Timeline toggle; this port's
-- own readouts - fight timer, score, resource, DST,
-- live hint - follow below that block)
-- * haste buttons 761-796 (Bloodlust/Haste-pot countdown buttons, minus the
-- archived sound hook; DST rides a session-local
-- toggle since its db option is out of scope)
-- * rotation selector 807-871 + set_locked_rotation 1258-1304 (session-local lock on
-- sim.locked_rotation; haste is cleared on lock)
-- * result popup 524-541 + fade 3284-3301 + show_result 3655-3665
-- * result font update 3667-3672
-- * session start/stop 927-1165 (minus the archived hooks into bar/player functions;
-- this port mirrors the simulation onto the addon's own
-- bar state instead - see sync_to_addon)
-- * start_fight 2807-2889 (reduced to the engine's fight lifecycle + the
-- timeline/band reset and the cue strip's re-anchor)
-- * icon state updates 3535-3610 (cooldown swipes kept on the addon's set-once
-- pattern per nextaction_ui.lua, plus the archived
-- buff/active-seal border highlights)
-- * rotation display 1461-1662 (center-screen name/desc/icons driven by
-- st.nextaction.sequences; position is session-local)
-- * rotation tracking 1554-1579 (band logging into the ui-owned band array; the
-- engine helper track_rotation_band is pure)
-- * timeline data 1881-1898 (event/band collection on the ui-owned arrays;
-- rendering below is a trimmed port of 1900-2510;
-- the LOG is now the opt-in Timeline view)
-- * keybind capture 261-332 + 4142-4196 (fullscreen capture overlay; bindings live
-- in the session-local ui.keybinds table and are
-- applied with SetOverrideBindingClick)
-- * proc icons 2715-2802 (Bloodlust + DST icons with swipe + countdown)
-- * sync_state_to_player 2542-2587 (mapped onto ST.PALADIN.practice_sync)
--
-- The engine (practice.lua) is pure and owns no clock: this module owns the clock, the
-- frames and the mirroring onto the live addon state:
-- * Session: /st practice (or the options button) toggles a practice session; start()
-- resets the sim and immediately begins a fight (begin_fight + apply_haste). The
-- panel's Start/Stop Fight button restarts/ends fights within the session; its X
-- button (or /st practice again) stops the session (stop()).
-- * Driver: this module's own OnUpdate frame, NOT the paladin class hook. The class
-- hook only fires while the swing bar is shown, but the driver must keep running
-- while the bar is hidden (and while a real-combat pause is waiting to end); the
-- 0.05 s compute throttle mirrors nextaction_ui's UPDATE_INTERVAL, and the result
-- popup fades on every frame for a smooth step between compute ticks.
-- * Real-combat pause (archive sim_on_swing 3434-3441, stricter here): while ST.in_combat
-- the sim is frozen entirely - no casts, no swing progression, no grading - and the
-- panel shows a paused hint. On resume every future sim deadline is shifted by the
-- pause duration (shift_sim_times) so nothing lands retroactively.
-- * Bridge: ST.mainhand/ST.gcd carry the simulated swing and GCD (sync_to_addon), the
-- seal state is mirrored by ST.PALADIN.practice_sync, and main.lua's hide_bar guard
-- keeps the mainhand bar visible for the session (the range-finder loop re-runs bar
-- visibility every 0.05 s).
-- * Icon swipes use the set-once latch pattern (nextaction_ui's set_icon_cooldown):
-- a swipe is started only when a cooldown appears or restarts, never re-set per frame.
--
-- Deviations / deferrals (documented for the later phases):
-- * Keybinds and bar mappings are session-local (ui.keybinds, from
-- st.practice.DEFAULT_KEYBINDS): the archived practice_key_* / practice_bar_mappings
-- persistence needs new options, which this lane may not add. Right-clicking a panel
-- icon assigns a key via the capture overlay; action-bar button discovery, manual
-- linking (click-catchers) and bar overlays plug in through ui.attach_overlay
-- (see the "Bar overlays (deferred)" section).
-- * The archive's calculate_spell_GCD_duration hook is emulated only through
-- gcd.spell_length here; the guide's cooldown inputs still come from the live
-- spell cooldowns until the phase-4 haste/rotation/guide integration.
-- * The AW macro options (practice_aw_includes_pot/sob, archive 3198-3208)
-- apply through cast_key below: pot sets the buff directly (no GCD,
-- dup- and lock-guarded like the archive), SoB casts the full seal when
-- the GCD is free. Both ride the Practice page toggles (nil = off).
-- * practice_weapon_speed is read at session/fight start; changing it mid-session
-- takes effect at the next start_fight.
-- * The session's timeline is the MOVING cue strip: a fixed playhead a quarter of
-- the way in, with the upcoming actions travelling right-to-left toward it (the
-- operator's practice request). It has no db options here - the horizon, the
-- playhead fraction and the geometry are constants (the maths live in the pure
-- engine as st.practice.TL_* / timeline_*), and the position is session-local.
-- The archived post-fight LOG stays available behind the panel's Timeline button
-- (default off), because what had already happened is a review, not a cue.
-- * The rotation display has no enable option: it shows during the session whenever
-- the rotation resolves. Its position is session-local (default CENTER + 0, +100).
-- * The DST proc rides the session-local ui.dst_enabled toggle, seeded from
-- the Practice page option (nil = off, the archived practice_dst_enabled
-- default); the panel toggle still flips it live within the session.
-- * Archive quirk: the panel's gcd_dim texture was created but never shown
-- (update_icon_states only fired the gcd_cooldown swipe). Phase 2 shows the dim on
-- GCD-gated icons during the simulated GCD, which is what the panel description says.
--
-- Per-class fidelity (what each drill mirrors, and what the sim cannot model):
-- * Paladin: the twist sim runs the engine's own rotation (determine_rotation),
-- real swing (equipped speed or the weapon-speed override) and the live GCD
-- model. Panel order is the engine's ABILITY_ORDER key-for-key.
-- * Hunter: forced-move windows with Steady (1.5 s) / Multi (0.5 s) /
-- Aimed (3.0 s) starts graded against the window. Times are the class's
-- PRACTICE_SHOTS constants, not live reads: Wrath's instant Aimed and the
-- Arcane/Chimera/Explosive vehicles are not modelled (class-side backlog:
-- practice_cast_time should resolve live per client like the icon ids do).
-- Casts carry no latency compensation (reaction drill; equal shift for all
-- shots, class-side backlog if the shell ever needs it).
-- * Shaman: weave starts graded against the drill swing by the weave model.
-- Swing is real (equipped speed or override), cast seconds ride the live
-- weave catalogue (harness defaults when the catalogue is silent) and
-- latency rides the live world-latency read. Only LB/CL casts exist as
-- vehicles (HW/CH/LvB families are a class-side table addition; the panel
-- already follows PRACTICE_CAST_ORDER dynamically).
-- * Rogue: pool spends graded against the real 2 s energy cadence, SS strikes
-- against the drill swing (Era sword timing only, per sim research that
-- refuted it on TBC+). Latency rides the harness default, not a live read
-- (class-side backlog: a practice_latency_seconds reader like the shaman's).
-- * Druid: powershifts graded against the harness energy model
-- (POWERSHIFT_DEFAULTS: 100 pool, 10/s regen, 40 shift refund, 20-energy
-- rule). Rates are harness defaults, not live reads (no class-side live
-- reader exists; in-client numbers owed before the model can claim them).
-- * Warrior: no drill shell exists, so the toggle runs the shared twist sim
-- session (the options page documents it as the shared session) while the
-- Execute report grades LIVE Execute-range entries (real rage, real target
-- HP, no sim). A warrior-native sim would need a class-side shell plus a
-- harness scenario plus a branch here; until then the shared session is
-- the documented behaviour, not a silent fallback.
--=========================================================================================
local addon_name, st = ...
local ST = LibStub("AceAddon-3.0"):GetAddon(addon_name)
local LSM = LibStub("LibSharedMedia-3.0")
-- Cross-client API wrappers (see compat.lua).
local api = st.api
st.practice_ui = st.practice_ui or {}
local ui = st.practice_ui
--=========================================================================================
-- Display constants
--=========================================================================================
local UPDATE_INTERVAL = 0.05 -- simulation tick cadence (guide/WA poll parity)
local ICON_SIZE = 30 -- archive geometry
local ICON_SPACING = 4
local ICONS_PER_ROW = 5
local PANEL_WIDTH = 210
local PANEL_ANCHOR_GAP = 8 -- archive: panel TOPLEFT 8 px right of the bar TOPRIGHT
local RESULT_WIDTH = 200
local RESULT_HEIGHT = 50
local RESULT_FADE_SECONDS = 0.3 -- archive: the popup fades over its final 0.3 s
local RESULT_FALLBACK_FONT = "Fonts\\FRIZQT__.TTF"
local RESULT_COLOR_HIT = { 0, 1, 0, 1 }
local RESULT_COLOR_MISS = { 1, 0.2, 0.2, 1 }
local HINT_ACTIVE = "Click an icon to cast. /st practice or the X button stops."
local HINT_STOPPED = "Press Start Fight, or cast seals with the icon grid."
local HINT_PAUSED = "Paused: in combat. Practice resumes when combat ends."
-- The reference panel's static rows (operator's screenshot): the bind instruction
-- under the icon grid and the (Reset) link under it. They are chrome, not status -
-- the dynamic hint (HINT_STOPPED and friends) keeps its own row at the panel's foot.
local HINT_BIND = "Right-click icon to map to action bar"
local HINT_RESET = "(Reset)"
-- Drill hints are the ACTIVE class's own wording: each class's practice_panel
-- carries hint_active / hint_stopped (class-isolation 2026-10-21), so the shared
-- module keeps no per-class strings.
-- Rotation scenario hints (the shared engine): the panel asks the class what it
-- wants cast and grades the player's cast against it.
local HINT_SCENARIO_ACTIVE = "Cast what the rotation wants. /st practice or the X button stops."
local HINT_SCENARIO_STOPPED = "The run is over. /st practice or the X button stops the session."
-- Drill-list hints (the shared engine): one button per drill, pressed to grade.
-- There is no separate "stopped" wording: the drill list has no pre-run state
-- (no fight to start), so the drills are always pressable.
local HINT_DRILLS_ACTIVE = "Press a drill to grade it. /st practice or the X button stops."
-- The practice mode the session runs in (the Practice page select):
-- "class" the shipped per-class session (the default; today's behaviour)
-- "scenario" the class's rotation SCENARIO (ST.<CLASS>.practice_scenario_step)
-- "drills" the class's focused DRILL list (ST.<CLASS>.practice_drills)
local PRACTICE_MODE_CLASS = "class"
local PRACTICE_MODE_SCENARIO = "scenario"
local PRACTICE_MODE_DRILLS = "drills"
-- The rotation scenario's decision window: how long the player has to cast the
-- action the class asked for before the step closes as a miss. [DESIGN]
local SCENARIO_STEP_SECONDS = 3.0
-- The practice row geometry (the companion strip under the panel's content that
-- carries the scenario's action buttons, the drill list's buttons, the prompt and
-- the status line).
--
-- The strip GROWS with the button lines (practice_row_set_height): 74 px is the
-- TWO-line minimum (the prompt line, two button lines and the status line), and
-- a class whose rotation spine needs more lines gets a taller strip - the
-- hunter's thirteen-row Wrath spine is four button lines, so the strip carries
-- it and the panel's height follows. Nothing here caps the row: a rotation row
-- is never cut off by the panel.
local PRACTICE_ROW_HEIGHT = 74
local PRACTICE_ROW_BUTTON_H = 20
local PRACTICE_ROW_BUTTON_GAP = 2
local PRACTICE_ROW_ACTION_W = 46
local PRACTICE_ROW_ACTION_PER_ROW = 4
local PRACTICE_ROW_DRILL_W = 96
local PRACTICE_ROW_DRILL_PER_ROW = 2
-- The twist drill is the PALADIN's own: the inherited twist session belongs to
-- the class that owns it and to nobody else.
local TWIST_DRILL_KEY = "paladin_twist"
-- Rotation display (archive 1501-1552, session-local position).
local ROT_ICON_SIZE = 40
local ROT_ICON_SPACING = 6
local ROT_DISPLAY_MAX_ICONS = 5
local ROT_DISPLAY_DEFAULT_X = 0
local ROT_DISPLAY_DEFAULT_Y = 100
-- Timeline (archive 162-166 + 2369+, trimmed; no db options in this lane).
-- `TIMELINE_WIDTH` is now the FALLBACK track width: both timeline frames size
-- themselves from the screen (the operator's reference is a full-width band across
-- the top), and a sandbox/client that reports no UIParent width falls back to this
-- constant so the geometry stays deterministic offline.
local TIMELINE_WIDTH = 600
local TIMELINE_PPS = 80
local TIMELINE_LANE_HEIGHT = 24
local TIMELINE_ICON_SIZE = 20
local TIMELINE_LABEL_WIDTH = 60
local TIMELINE_RULER_HEIGHT = 46
local TIMELINE_DEFAULT_X = 0 -- session-local default centre offset (archive layout)
local TIMELINE_DEFAULT_Y = 300 -- near the top of the screen (operator's reference layout)
local TIMELINE_SCREEN_MARGIN = 10 -- left/right gap of a full-width frame
local TIMELINE_MAX_TRACK = 2000 -- never wider than a 2K client needs
local ROTATION_BAND_ICON_SIZE = 14
local ROTATION_BAND_ICON_SPACING = 2
local ROTATION_BAND_MAX_ICONS = 5
-- Moving cue strip geometry (see "Moving cue strip" below). The strip wears the
-- archive timeline's clothes (operator 2026-10-26: "Practice should look like this"):
-- a left lane-label column, a ruler with second ticks, alternating lane rows and a
-- horizontal scrollbar, with the same fixed playhead a quarter of the way in. The
-- playhead fraction and horizon live in the pure engine
-- (st.practice.TL_PLAYHEAD_FRACTION / TL_HORIZON_SECONDS) so the suites can pin the
-- maths without frames.
--
-- Scale: the reference screenshot is a wide band (roughly 13 s visible, the playhead
-- a quarter in, chunky lane rows with room above/below the icons). The track is the
-- screen width, so the row/ruler/icon sizes below are absolute and the visible
-- horizon (`TL_HORIZON_SECONDS`) is what fixes the pixels-per-second at any width.
local PLAYHEAD_LABEL_WIDTH = 110 -- left lane-label column (one icon + name per lane)
local PLAYHEAD_RULER_HEIGHT = 56 -- top ruler band (rotation band + second ticks)
local PLAYHEAD_ICON_SIZE = 24
local PLAYHEAD_ROW_HEIGHT = 30 -- one lane row (the reference's chunky rows)
local PLAYHEAD_POOL = 20 -- cues drawn per frame (the soonest ones; a denser provider caps here)
local PLAYHEAD_RULER_POOL = 26 -- second ticks drawn per frame (a 13 s window needs ~14)
local PLAYHEAD_SCHEDULE_INTERVAL = 0.1 -- schedule rebuild throttle (the tick drives it)
--=========================================================================================
-- Screen-sized timeline geometry
--=========================================================================================
-- The screen width the client reports, or 0 when it reports none (the offline suites
-- build UIParent without a size, and a client can answer before UIParent is laid out).
local function screen_width()
local parent = UIParent
if not parent or type(parent.GetWidth) ~= "function" then
return 0
end
local ok, width = pcall(parent.GetWidth, parent)
if not ok or type(width) ~= "number" or width <= 0 then
return 0
end
return width
end
-- The track width of one timeline frame (the strip and the log share the maths):
-- the screen width minus the frame margins and `rail` (the frame's own label
-- column), clamped to the fallback geometry and a sane maximum. The screen width
-- is an argument, so the suites pin the scale without frames.
function ui.timeline_track_width(rail, minimum, width)
minimum = tonumber(minimum) or TIMELINE_WIDTH
rail = tonumber(rail) or 0
width = tonumber(width) or screen_width()
if width <= 0 then
return minimum
end
local track = width - 2 * TIMELINE_SCREEN_MARGIN - rail - 12
if track < minimum then
return minimum
end
if track > TIMELINE_MAX_TRACK then
return TIMELINE_MAX_TRACK
end
return math.floor(track + 0.5)
end
-- Timeline lane -> spell id for lane icons and split-seal textures.
local LANE_SPELL_IDS = {
sob = 31892, cs = 35395, consec = 26573, soc = 20375, judge = 20271,
}
-- Rotation step -> spell id for the rotation display icons (twist is split halves).
local STEP_SPELL_IDS = { cs = 35395, cs_late = 35395, blood_auto = 31892, cmd_auto = 20375 }
local BLOOD_SPELL_ID = 31892
local COMMAND_SPELL_ID = 20375
local BLOODLUST_SPELL_ID = 2825
local BLOODLUST_FALLBACK = "Interface\\Icons\\Spell_Nature_Bloodlust"
local DST_ITEM_ID = 28830
local DST_FALLBACK = "Interface\\Icons\\INV_Misc_Bone_06"
-- Per-rotation band colours (archive 2148-2158).
local ROTATION_BAND_COLORS = {
["1/2"] = { 0.12, 0.25, 0.50 },
["2/3"] = { 0.40, 0.15, 0.50 },
["1/3"] = { 0.15, 0.40, 0.25 },
["2/4"] = { 0.50, 0.30, 0.10 },
["2/5_blood"] = { 0.50, 0.12, 0.12 },
["2/5_command"] = { 0.45, 0.45, 0.12 },
["2/2/5"] = { 0.15, 0.35, 0.50 },
["2007"] = { 0.35, 0.20, 0.40 },
}
local ROTATION_BAND_FALLBACK = { 0.30, 0.30, 0.30 }
-- Rotation selector grid (archive 816-822).
local ROT_GRID = { { "auto", "1/2" }, { "2/3", "2/4" } }
local ROT_LABELS = { auto = "Raid Pull", ["1/2"] = "1/2", ["2/3"] = "2/3", ["2/4"] = "2/4" }
-- Bare modifier keys the capture overlay ignores (archive 237-242).
local IGNORE_KEYS = {
LSHIFT = true,
RSHIFT = true,
LCTRL = true,
RCTRL = true,
LALT = true,
RALT = true,
UNKNOWN = true,
}
-- Buff border highlights (archive update_icon_states 3564-3592).
local BORDER_IDLE = { 0.3, 0.3, 0.3, 0.8 }
local BORDER_SEAL = { 0.0, 0.8, 0.0, 0.9 }
local BORDER_AW = { 1.0, 0.8, 0.0, 0.9 }
local BORDER_HPOT = { 1.0, 0.55, 0.0, 0.9 }
local BORDER_ABACUS = { 0.6, 0.2, 0.8, 0.9 }
-- Future sim deadlines that the real-combat pause must carry over the pause (see
-- shift_sim_times). Cooldowns of abilities the sim tracks, buff windows, the seal fade,
-- the swing, the GCD and the queue timestamp all live on the same clock domain.
local SHIFTED_SIM_TIMES = {
"swing_ends_at",
"gcd_expires",
"fading_seal_expires",
"bl_buff_expires",
"haste_pot_buff_expires",
"abacus_buff_expires",
"abacus_cooldown_expires",
"dst_buff_expires",
"dst_icd_expires",
"cs_cooldown_expires",
"judge_cooldown_expires",
"consec_cooldown_expires",
"aw_cooldown_expires",
"aw_buff_expires",
"queue_timestamp",
}
--=========================================================================================
-- Small helpers
--=========================================================================================
-- Anchors a practice frame to the mainhand bar (the archive's geometry), falling back
-- to UIParent's centre when the bar frame does not exist. The frames are built in
-- post_init, after init_timers, so the fallback is only a guard.
local function anchor_to_mainhand_bar(self, frame, point, bar_point, x, y)
frame:ClearAllPoints()
local bar = self:get_bar_frame("mainhand")
if bar then
frame:SetPoint(point, bar, bar_point, x, y)
else
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
end
end
-- The equipped mainhand swing speed (the archive's UnitAttackSpeed source), or nil.
local function get_equipped_speed()
local speed = api.UnitAttackSpeed("player")
if type(speed) == "number" and speed > 0 then
return speed
end
return nil
end
-- Base swing speed for a session: the configured override when set (a positive
-- practice_weapon_speed), else the equipped weapon, else the engine's 3.5 s fallback.
local function resolve_base_speed(db)
local override = type(db) == "table" and db.practice_weapon_speed or nil
if type(override) == "number" and override > 0 then
return override
end
return get_equipped_speed() or 3.5
end
-- Icon texture for a spell id through the cross-client wrappers (nil when unknown).
local function spell_texture(spell_id)
if not spell_id then
return nil
end
if api.GetSpellInfo then
local _, _, icon = api.GetSpellInfo(spell_id)
if icon then
return icon
end
end
if GetSpellTexture then
local icon = GetSpellTexture(spell_id)
if icon then
return icon
end
end
return nil
end
-- The four per-class drill routes (hunter / shaman / rogue / druid) live in the
-- class modules' OWN `practice_panel` providers (class-isolation 2026-10-21
-- C1-C4). The shared layer asks the ACTIVE class through ui.practice_panel()
-- only: it never names a class and never compares a hardcoded drill key.
-- The pure harness (the shared engine), or nil when the flavor ships no engine.
local function practice_harness()
local pr = st.practice
if not pr then
return nil
end
return pr.harness
end
-- The class module that owns the practice contract (the addon object owns the
-- class modules; this module's own table is the fallback for a half-loaded
-- addon). Nil when the class module is absent.
--
-- C7 (class-isolation 2026-10-21): the class is the one main.lua ACTIVATED
-- (ST.active_class), never the class being played (ST.player_class). An explicit
-- non-playing pick owns its practice; with no active class the shared layer has
-- no class features and never guesses.
local function practice_class_module()
local class_key = ST.active_class
if type(class_key) ~= "string" or class_key == "" then
return nil
end
local module = type(ST) == "table" and ST[class_key] or nil
if type(module) ~= "table" then
module = type(st) == "table" and st[class_key] or nil
end
if type(module) ~= "table" then
return nil
end
return module
end
-- The class's CANONICAL key for an action key: the class's own alias table maps
-- every spelling the engine, the practice sim or the guide may report onto the
-- canonical key (the paladin's "consecration" -> "cons"; the sim's own
-- "consec" -> "cons"). A class with a single key space publishes no table, and
-- the normaliser is then the identity.
--
-- The ENGINE owns the class contract (practice.lua's H.practice_canonical_key)
-- and this is the UI's seam over it. It is a ui-table member and NOT a
-- file-scope local on purpose: this module sits at the Lua 5.1 local ceiling
-- (199 of 200), so a 200th file-scope local would be a LOAD FAILURE.
function ui.canonical_key(key)
local H = practice_harness()
if not H or type(H.practice_canonical_key) ~= "function" then
return key
end
return H.practice_canonical_key(practice_class_module(), key)
end
-- The drill ids that belong to THIS class: the class module's published drill
-- list (the new drills) and the harness registry's entries for the class (the
-- shipped drills). A class's own drills win over any inherited or shared key.
local function practice_own_drill_keys(class_key)
local out = {}
local seen = {}
local H = practice_harness()
if not H then
return out
end
local function push(key)
if type(key) ~= "string" or key == "" or seen[key] then
return
end
seen[key] = true
out[#out + 1] = key
end
local drills = H.practice_drills(practice_class_module())
if drills then
for i = 1, #drills do
push(drills[i].key)
end
end
local specs = H.scenarios_for_class(class_key)
for i = 1, #specs do
push(specs[i].key)
end
return out
end
-- Is the drill key one of the class's own?
local function practice_owns_drill(own_keys, key)
if type(own_keys) ~= "table" or type(key) ~= "string" then
return false
end
for i = 1, #own_keys do
if own_keys[i] == key then
return true
end
end
return false
end
-- Does the class publish its own drills (the contract member)? A class that does
-- never falls back to the inherited twist session.
local function practice_publishes_drills()
local H = practice_harness()
if not H then
return false
end
local drills = H.practice_drills(practice_class_module())
return drills ~= nil and #drills > 0
end
-- The built-in drill the class's shipped session grades (its drill key), or nil.
-- The class module names it: ST.<CLASS>.practice_scenario_key() (the hunter's
-- accessor carries the older get_ prefix - both spellings are read).
local function practice_class_scenario_key()
local module = practice_class_module()
if type(module) ~= "table" then
return nil
end
local fn = module.practice_scenario_key
if type(fn) ~= "function" then
fn = module.get_practice_scenario_key
end
if type(fn) ~= "function" then
return nil
end
-- pcall-guarded: a throwing accessor never stops the session.
local ok, key = pcall(function() return fn(module) end)
if not ok or type(key) ~= "string" or key == "" then
return nil
end
return key
end
-- Does the class's own drill list supersede the built-in drill its shipped
-- session would run? (one skill, one truth - the class list wins)
local function practice_own_drill_supersedes_route()
local H = practice_harness()
if not H then
return false
end
local session_drill = practice_class_scenario_key()
if type(session_drill) ~= "string" then
return false
end
local superseding = H.SUPERSEDED_DRILLS[session_drill]
if type(superseding) ~= "string" then
return false
end
local drills = H.practice_drills(practice_class_module())
if not drills then
return false
end
for i = 1, #drills do
if drills[i].key == superseding then
return true
end
end
return false
end
-- Does the class own the twist drill (the paladin's own)? The twist session
-- belongs to the class that owns it and to nobody else.
local function practice_owns_twist_drill()
return practice_owns_drill(practice_own_drill_keys(ST.active_class), TWIST_DRILL_KEY)
end
-- Does the class's OWN drill list win over the session the class would otherwise
-- run? Two cases:
-- * the class's list supersedes a built-in drill that no shipped session of
-- its own grades (one skill, one truth - the operator's ruling), or
-- * the class has no shipped session of its own and does not own the twist
-- drill (the twist session is the paladin's, never an inherited session).
--
-- A class WITH a shipped session of its own (the hunter, the shaman, the rogue,
-- the druid) keeps that session: its session grades through the class's own drill
-- (H.delegate_drill_grade), so the skill stays one truth - but the session itself
-- must stay REACHABLE.
local function practice_class_list_wins()
if not practice_publishes_drills() then
return false
end
-- (b) A class with a SHIPPED session of its own (the hunter, the shaman, the
-- rogue, the druid) keeps it: its session grades through the class's own
-- drill (H.delegate_drill_grade), so the skill is still one truth - and
-- the session itself stays REACHABLE. Routing it to the drill list
-- instead left the class with no session at all: its panel icons then
-- hit a drill cast with no session (a silent no-op) and the class's own
-- session was unreachable.
if ui.practice_class_drill() then
return false
end
-- (a) The class's list supersedes the built-in drill its shipped session
-- grades: the class trains its own list instead.
if practice_own_drill_supersedes_route() then
return true
end
-- (c) No shipped session of its own: the class would inherit the twist
-- session, and it never does when it has drills of its own.
return not practice_owns_twist_drill()
end
-- Does this class run a SIM session (the twist sim or one of the class drill
-- sessions)? A class whose own drill list wins runs its own drill list instead -
-- it never falls back to the twist session.
local function practice_class_runs_sim_session()
if ui.practice_class_drill() then
if not practice_class_list_wins() then
return true
end
end
-- The twist sim is the PALADIN's drill, never an inherited session: a class
-- that does not own the twist drill runs no sim session at all (the operator
-- ruling - no class is offered another class's drill). A class with no
-- practice of its own is refused loudly by ui.start.
return practice_owns_twist_drill()
end
-- Is the swing engine LIVE on this client? The engine reports its mode on the
-- addon object (main.lua): "vendored-lib" (the classic line), "forever-source"
-- (the Forever PLAYER_SWING engine) or "inert-standin" (the retail client, which
-- has no classic combat log and keeps a stand-in that never reports a swing).
--
-- The rotation SCENARIO needs a live swing engine: the rotation the class asks
-- for is timed off the swing, so on the inert client every step would answer
-- UNKNOWN (ten unknown steps instead of a session). The scenario is gated on the
-- engine and the refusal is loud.
--
-- Only the EXPLICIT stand-in counts as dead: a client that has not reported its
-- engine yet (or a flavour that does not set the field at all) is unknown, and
-- an unknown engine never blocks a session.
local function practice_swing_engine_dead()
local engine = ST.swing_engine
if type(engine) ~= "string" or engine == "" then
return false
end
return engine == "inert-standin"
end
-- The class's scenario id (optional): the class lane owns the name its rotation
-- scenario is registered under. It may publish a string or a function that
-- returns one; the engine's own table is the fallback.
local function practice_scenario_id()
local module = practice_class_module()
if type(module) ~= "table" then
return nil
end
local key = module.practice_scenario_key
if type(key) == "string" then
return (key ~= "") and key or nil
end
if type(key) ~= "function" then
return nil
end
-- pcall-guarded: a throwing accessor never stops the session (the engine's
-- own id stands in).
local ok, resolved = pcall(function() return module.practice_scenario_key() end)
if not ok or type(resolved) ~= "string" or resolved == "" then
return nil
end
return resolved
end
-- The rotation the class publishes (optional): the class lane may expose the
-- rotation slice it mirrors (ST.<CLASS>.practice_rotation_for). The engine uses
-- the actions it names - in the rotation's own priority order, with the class's
-- own labels - for the scenario panel's buttons. A class that publishes none
-- keeps the guide slot vocabulary.
local function practice_rotation_entries()
local module = practice_class_module()
if type(module) ~= "table" or type(module.practice_rotation_for) ~= "function" then
return nil
end
-- The call is argument-free on purpose: the class picks its own slice (the
-- running family / order). pcall-guarded: a class that needs an argument
-- answers nil and the guide slot vocabulary stands in.
local ok, entries = pcall(function() return module.practice_rotation_for() end)
if not ok or type(entries) ~= "table" then
return nil
end
local out = {}
for i = 1, #entries do
local entry = entries[i]
if type(entry) == "table" and type(entry.action) == "string" and entry.action ~= "" then
out[#out + 1] = {
action = entry.action,
label = (type(entry.label) == "string" and entry.label ~= "") and entry.label
or entry.action,
}
end
end
if #out == 0 then
return nil
end
return out
end
-- The rotation family the running client uses (optional): the class lane may
-- publish it (ST.<CLASS>.practice_rotation_family). It is the panel's readout -
-- which rotation the scenario is grading against.
local function practice_rotation_family()
local module = practice_class_module()
if type(module) ~= "table" or type(module.practice_rotation_family) ~= "function" then
return nil
end
local ok, family = pcall(function() return module.practice_rotation_family() end)
if not ok or type(family) ~= "string" or family == "" then
return nil
end
return family
end
-- Start-block accounting (the start-path analogue of the tick's note_practice_error
-- below): every refused start counts in self.bar_hook_errors (read by /st diag) and
-- prints its reason, so no start path ever dies silent. Unlike the tick site
-- (20/s), starts are user-invoked, so every refusal prints (no once-per-session
-- throttle); the counter still lets /st diag show the volume.
--
-- The contract itself lives in guard.lua (one implementation for the whole addon):
-- this is the refusal flavour of it (count + print every time).
local START_BLOCK_SITE = "practice:start"
local function note_start_block(self, msg)
st.guard.refusal(self or ST, START_BLOCK_SITE, msg)
end
-- A drill cast that reaches a class drill with NO session is a LOUD refusal,
-- never a silent no-op: the player clicked a drill icon and nothing happened.
-- The refusal is counted (the /st diag report reads the count) and printed with
-- its reason.
local CAST_BLOCK_SITE = "practice:cast"
local function note_cast_block(self, msg)
st.guard.refusal(self or ST, CAST_BLOCK_SITE, msg)
end
--=========================================================================================
-- The active class's practice provider (class-isolation 2026-10-21)
--=========================================================================================
-- ONE uniform contract between the shared practice UI and whichever class module
-- main.lua activated (R2 shape (a): the class publishes a provider table). The
-- shared layer reads this table generically: it never names a class, never
-- compares a hardcoded drill key, and never indexes a missing function without a
-- guard. A class with no provider simply has no drill panel; every caller then
-- degrades to the paladin sim / the drill list / "no practice for this class".
--
-- These are ui-table members rather than file-scope locals on purpose: this
-- module sits at the Lua 5.1 local ceiling (199 of 200), so one more file-scope
-- local would be a LOAD FAILURE.
function ui.practice_panel()
local module = practice_class_module()
if type(module) ~= "table" then
return nil, nil
end
local panel = module.practice_panel
if type(panel) ~= "table" then
return nil, module
end
return panel, module
end
-- The active class's drill scenario key (throw-guarded; nil on drift).
function ui.practice_drill_scenario_key()
local panel = ui.practice_panel()
if not panel or type(panel.scenario_key) ~= "function" then
return nil
end
local ok, key = pcall(panel.scenario_key)
if not ok or type(key) ~= "string" or key == "" then
return nil
end
return key
end
-- Does the ACTIVE class's own drill scenario run? The class declares its drill
-- key and its live scenario key; the route matches only when they agree.
function ui.practice_class_drill()
local panel = ui.practice_panel()
if not panel or type(panel.drill_key) ~= "string" or panel.drill_key == "" then
return false
end
return ui.practice_drill_scenario_key() == panel.drill_key
end
-- The active class's drill order, or nil for a class without a drill panel.
function ui.practice_class_order()
if not ui.practice_class_drill() then
return nil
end
local panel = ui.practice_panel()
if not panel or type(panel.order) ~= "function" then
return nil
end
local ok, order = pcall(panel.order)
if not ok or type(order) ~= "table" then
return nil
end
return order
end
-- The active class's drill definition for one key (the class's own label and
-- spell-id/name art rule), or nil when the class cannot map the key.
function ui.practice_class_def(key)
local panel = ui.practice_panel()
if not panel or type(panel.label) ~= "function" then
return nil
end
local ok, label = pcall(panel.label, key)
if not ok or label == nil then
return nil
end
local spell_id = nil
if type(panel.spell_id) == "function" then
local ok_sid, sid = pcall(panel.spell_id, key)
if ok_sid then
spell_id = sid
end
end
local def = {
name = label,
spell_id = spell_id,
cooldown = 0,
type = panel.def_type or "ability",
}
if panel.icon == "name" and api.GetSpellInfo then
local name = label
if name then
local _, _, icon = api.GetSpellInfo(name)
if icon then
def.texture = icon
end
end
end
return def
end
-- The order the panel/keybind surfaces use: the active class's own drill order
-- when its route runs, else the shared sim's ability order.
function ui.practice_order()
local order = ui.practice_class_order()
if order then
return order
end
local pr = st.practice
return (pr and pr.ABILITY_ORDER) or {}
end
-- A drill class whose module is present but whose scenario key no longer matches
-- (renamed harness key, half-loaded module) never silently runs the paladin sim:
-- the route is broken and start fails it closed with the class's own message.
function ui.practice_drill_broken()
local panel = ui.practice_panel()
if not panel or type(panel.drill_key) ~= "string" or panel.drill_key == "" then
return false
end
return not ui.practice_class_drill()
end
-- Is one of the active class's drill sessions running?
function ui.practice_drill_running()
local panel = ui.practice_panel()
if not panel or type(panel.session) ~= "table"
or type(panel.session.running) ~= "function" then
return false
end
local ok, running = pcall(panel.session.running, ui)
return ok and running == true
end
-- Start the active class's own drill session through its provider, then run the
-- shared session trailer (fresh score, progression, panel). Returns false when
-- the class has no runnable shell.
function ui.practice_start_drill(self, now)
local panel = ui.practice_panel()
if not panel or type(panel.session) ~= "table"
or type(panel.session.start) ~= "function" then
return false
end
-- The panel order must resolve key-for-key (unknown-hide in build): a drill
-- whose icons cannot resolve refuses to start (fail-loud) rather than
-- running a session whose casts never score.
local order = ui.practice_class_order()
if type(order) ~= "table" or #order == 0 then
return false
end
for i = 1, #order do
if ui.practice_class_def(order[i]) == nil then
return false
end
end
local db = self:get_class_table()
local duration = resolve_base_speed(db)
if type(duration) ~= "number" or duration <= 0 then
duration = 3.5
end
local ok, started = pcall(panel.session.start, self, now, ui,
{ base_speed = duration })
if not ok or not started then
return false
end
return ui.begin_drill_session(self, now)
end
-- The shared drill-session trailer: everything every class's own drill start
-- does after it has built its session state.
function ui.begin_drill_session(self, now)
local pr = st.practice
if not pr then
return false
end
ui.active = true
ui.combat_paused = false
ui.last_compute = 0
ui.result_remaining = 0
-- Drill casts start unbound (the keybind capture flow assigns them); the
-- paladin defaults do not apply to drill keys.
ui.keybinds = {}
ui.score = pr.new_score()
-- The progression hook: a fresh pure session, the live clock stamp and the
-- drill key (the session's graded attempts feed it from here on).
ui.begin_progress_session(self, now)
ui.load_bests(self)
ui.last_score_text = nil
ui.last_seal_sig = nil
ui.last_rotation_line = nil
ui.last_fight_text = nil
ui.last_info_text = nil
ui.last_button_active = nil
ui.last_hint_text = nil
ui.current_display_rotation = nil
ui.build_bar_mappings(self)
ui.update_result_font(self)
ui.refresh_icons(self)
ui.update_info(self)
ui.update_score(self)
ui.update_button(self)
ui.update_hint(self)
ui.update_keybind_labels(self)
ui.rebind_keys(self)
ui.apply_settings(self)
-- The moving cue strip: the drill's own timeline. Before this the class drills
-- had NO timeline at all (the log's toggle button was only built for the sim
-- session), so nothing told the player what to press next.
-- A drill session that opened its own fight runs the strip from here; one that
-- opens stopped waits for Start Fight (the operator's start/stop rule).
ui.playhead_session_open(self, now, ui.practice_drill_active() == true)
ui.panel:Show()
ui.driver:Show()
ui.sync_to_addon(self)
self:Print("Practice mode started. Type /st practice to stop.")
return true
end
-- The running drill session's fight state (nil when no class drill session
-- exists or the provider has no state seam).
function ui.practice_drill_active()
local panel = ui.practice_panel()
if not panel or type(panel.session) ~= "table"
or type(panel.session.active) ~= "function" then
return nil
end
local ok, active = pcall(panel.session.active, ui)
if not ok then
return nil
end
return active
end
-- Begin (or re-begin) the active class's drill run - the Start/Stop Fight button.
function ui.practice_begin_fight(self, now)
local panel = ui.practice_panel()
if not panel or type(panel.session) ~= "table"
or type(panel.session.begin) ~= "function" then
return false
end
local ok = pcall(panel.session.begin, self, now, ui)
if not ok then
note_start_block(self, "Practice drill fight cannot begin (shell error).")
return false
end
return true
end
-- End the active class's drill run (the session object survives for a re-begin).
function ui.practice_end_fight(self)
local panel = ui.practice_panel()
if not panel or type(panel.session) ~= "table"
or type(panel.session.end_session) ~= "function" then
return false
end
pcall(panel.session.end_session, self, ui)