Repository navigation
Expand file tree
/
Copy pathpractice_progress.lua
More file actions
1473 lines (1367 loc) · 59.1 KB
/
Copy pathpractice_progress.lua
File metadata and controls
1473 lines (1367 loc) · 59.1 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
--=========================================================================================
-- Practice progression (pure): XP, levels, renown, per-drill mastery, streaks, daily XP
-- cap, the generic unlock registry, and the SwedgeTimerDB.profile.practice shape.
--
-- Design source: bench/practice_gamification_design_2026-10-19.md (spec §2 progression,
-- §4 achievements, §5 rewards, §6 engineering) plus the operator order of 2026-10-19
-- that the unlock registry carries TWO kinds (cosmetic unlocks and level-gated drill
-- variants). Foundation lane (spec §7 step 1): the rules live here, offline and
-- deterministic; the hook lane (step 2) feeds them from a live session, the UI lane
-- (step 3) reads the snapshot, step 5 exports the payload.
--
-- PURE by construction: no WoW API, no os.time, no randomness, no clock read, no
-- addon-owned state. The caller injects everything time-shaped:
-- * durations -> seconds (a number)
-- * dates -> a day ordinal (a number) or a "YYYY-MM-DD" string (day_ordinal)
-- Malformed input degrades to zero / defaults; nothing here raises on bad data.
--
-- Ownership split with practice_stats.lua (one owner per number, no duplication):
-- progress : xp, level, renown, per-drill mastery + tier, the streaks (session /
-- best / daily), the daily per-drill XP cap, the unlock registry and
-- unlock state, and the saved-profile shape (schema_version / migrate /
-- validate).
-- stats : lifetime aggregates (runs / attempts / hits / misses / accuracy / best
-- session score / best grade / time active), the per-drill attempt table,
-- the 50-session history ring, the stats snapshot and its export payload.
-- The merged read model - a level next to the stats numbers, mastery inside the
-- per-drill rows - is profile_snapshot below (it takes the stats snapshot as data, so
-- the two modules stay independent).
--
-- Load order: practice_stats.lua loads first (this module composes the saved profile
-- from both halves). Both are listed after practice.lua and before practice_ui.lua in
-- every flavour toc.
--=========================================================================================
local _, st = ...
st.practice_progress = st.practice_progress or {}
local P = st.practice_progress
--=========================================================================================
-- Constants
--=========================================================================================
-- Bumped when the persisted state shape changes; migrate() moves older blobs forward.
P.STATE_VERSION = 1
-- The persisted profile schema version (spec §6 "Persistence").
P.SCHEMA_VERSION = 1
-- Grade points, mirrored from st.practice.GRADE_POINTS (practice.lua:1339). The live
-- table wins at call time so a retune of the scoring model flows into XP without
-- touching this file; the mirror is the offline fallback. An F earns nothing - a
-- miss costs nothing, it just does not earn (spec §2). The grade RANK comparison is
-- not repeated here: the best grade belongs to the stats module, which owns it.
P.GRADE_POINTS = { S = 100, A = 80, B = 60, C = 40, D = 20, F = 0 }
-- The grade set the scoring model produces. F is the only zero-point grade and the
-- only miss (practice.lua:1393 grades a hit as anything but "F").
P.GRADES = { S = true, A = true, B = true, C = true, D = true, F = true }
-- Level curve: xp_for_level(n) = floor(100 * n^1.35) (spec §2 "Level curve") - the
-- XP needed to advance FROM level n TO level n+1. Diminishing returns, no wall.
P.LEVEL_CURVE_BASE = 100
P.LEVEL_CURVE_EXPONENT = 1.35
-- Level cap per class; past it the ladder becomes renown (spec §2 "Level cap"):
-- level 51 is renown 1, level 52 is renown 2.
P.MAX_LEVEL = 50
-- Session completion bonus (spec §2): +25 * completion_bonus_factor when a session
-- ends with at least 10 attempts - it rewards finishing a session, not grinding it.
P.SESSION_BONUS_XP = 25
P.SESSION_BONUS_MIN_ATTEMPTS = 10
-- The factor the spec names but does not value. The identity (1.0) is the flat +25
-- bonus as written; a later design pass may scale it.
P.COMPLETION_BONUS_FACTOR = 1.0
-- The completion bonus is a SHARE of what the session actually EARNED, never of how
-- many attempts it made (M3, re-audit 2026-10-20): an attempt is any graded attempt,
-- so ten misses are ten attempts and zero XP - and a bonus for zero XP is a grind
-- hole. The share is taken from the ALREADY capped awarded XP, so the daily
-- anti-grind cap still bounds the total: the bonus can only ever add a fraction of
-- what the cap allowed, and it never exceeds the flat +25 the spec writes.
P.SESSION_BONUS_XP_SHARE = 0.5
-- Mastery per drill (spec §2): recency-weighted accuracy over the last 100 attempts
-- with an exponential decay of a 30-attempt half-life, gated on 20 attempts of
-- volume (below that the drill reads Unranked - one lucky S never grants mastery).
P.MASTERY_WINDOW = 100
P.MASTERY_HALF_LIFE = 30
P.MASTERY_MIN_ATTEMPTS = 20
-- Mastery tiers: the accuracy and the lifetime attempt count a drill must reach.
P.MASTERY_TIERS = {
bronze = { pct = 0.60, attempts = 20 },
silver = { pct = 0.75, attempts = 50 },
gold = { pct = 0.85, attempts = 100 },
platinum = { pct = 0.95, attempts = 200 },
}
-- Tier order, for comparisons (a higher rank is a better tier).
P.TIER_RANK = { unranked = 0, bronze = 1, silver = 2, gold = 3, platinum = 4 }
-- The tier labels as they are displayed.
P.TIER_NAMES = {
unranked = "Unranked",
bronze = "Bronze",
silver = "Silver",
gold = "Gold",
platinum = "Platinum",
}
-- Anti-grind (spec §2): the XP a single drill can earn per day is capped at 3x the
-- drill's median attempt XP. One drill cannot be farmed for infinite levels.
--
-- H-1 (re-audit 2026-10-20): the median is taken over the drill's HITS, not over
-- every attempt. A miss (F) earns nothing, so a drill below 50% accuracy had a
-- median of 0 and a cap of 0 - and every award was swallowed for good. The cap
-- keeps the spec's meaning (3x what the drill earns) and can no longer collapse.
P.DAILY_CAP_MULTIPLIER = 3
-- The scope key that reads the account scope (the sum over the classes).
P.GLOBAL_SCOPE = "GLOBAL"
-- Unlock kinds (operator order 2026-10-19): the registry carries both a cosmetic
-- kind and a drill-variant kind, and the spec's §4 achievements are the third kind.
P.KIND_ACHIEVEMENT = "achievement"
P.KIND_COSMETIC = "cosmetic"
P.KIND_DRILL_VARIANT = "drill_variant"
--=========================================================================================
-- Guards: malformed input degrades to zero / the default; nothing here raises
--=========================================================================================
-- A finite number, or the fallback. Rejects non-numbers, NaN and the infinities.
local function num(value, fallback)
if type(value) ~= "number" then return fallback end
if value ~= value then return fallback end -- NaN
if value == math.huge or value == -math.huge then return fallback end
return value
end
-- A finite, non-negative integer counter. Counts saturate at 2^53, the largest
-- exactly representable integer in a double - past that a counter is a corrupt value,
-- not a real count.
local function count(value)
local v = num(value, 0)
if v < 0 then return 0 end
if v > 2 ^ 53 then return 2 ^ 53 end
return math.floor(v)
end
--=========================================================================================
-- Grade model (the live scoring table wins; the mirror is the fallback)
--=========================================================================================
-- The grade set in force: the live scoring model's keys when it is loaded, else the
-- mirror above. A grade outside the set is not an attempt (the caller only ever
-- passes real grades; a corrupt ring entry is repaired at load time).
local function grade_set()
local live = st.practice and st.practice.GRADE_POINTS
if type(live) == "table" then
local set = {}
for grade in pairs(live) do
if type(grade) == "string" then set[grade] = true end
end
if next(set) then return set end
end
return P.GRADES
end
-- Points for one grade (0 for an unknown grade).
local function grade_points(grade)
local live = st.practice and st.practice.GRADE_POINTS
local points
if type(live) == "table" then points = live[grade] end
if type(points) ~= "number" then points = P.GRADE_POINTS[grade] end
if type(points) ~= "number" or points ~= points then return 0 end
if points == math.huge or points == -math.huge then return 0 end
return points
end
-- A hit is any grade but F - the same definition the scoring model uses
-- (practice.lua:1393).
local function is_hit(grade)
return grade ~= "F"
end
-- Is the grade one the scoring model produces? The live scoring model's own grade
-- set is the truth; the mirror above is the offline fallback.
--
-- L-4 (re-audit 2026-10-20): the live score model counts ANY grade it is handed
-- (st.practice.record_attempt), while the pure modules drop a grade outside the
-- set - so a stray grade advanced the session score and vanished from the
-- progression, and the two drifted apart. The caller can now ask the pure side
-- before it records anything, and both pure modules answer the same way.
function P.known_grade(grade)
return grade_set()[grade] == true
end
--=========================================================================================
-- Calendar day helper (injected dates only - no os.time anywhere in this module)
--=========================================================================================
local MONTH_LENGTHS = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
local function is_leap_year(year)
return (year % 4 == 0 and year % 100 ~= 0) or (year % 400 == 0)
end
local function days_in_month(year, month)
if month == 2 and is_leap_year(year) then return 29 end
return MONTH_LENGTHS[month] or 0
end
-- Days since 1970-01-01 from a proleptic Gregorian date (Howard Hinnant's civil
-- calendar algorithm): pure integer maths, no library and no clock.
local function days_from_civil(y, m, d)
y = y - (m <= 2 and 1 or 0)
local era = math.floor((y >= 0 and y or y - 399) / 400)
local yoe = y - era * 400
local doy = math.floor((153 * (m + (m > 2 and -3 or 9)) + 2) / 5) + d - 1
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
return era * 146097 + doe - 719468
end
-- The day ordinal of an injected date value. Two accepted forms:
-- * a number - an opaque day counter the caller converts the client's calendar
-- date into; consecutive days differ by exactly 1.
-- * a string - "YYYY-MM-DD", run through the civil calendar above, so month and
-- year boundaries need no lookup table.
-- Anything else (nil, a boolean, a malformed date) yields nil: the caller has no
-- calendar day, and the day-based rules simply do not advance.
--
-- THE NUMBERING (L-6, re-audit 2026-10-20): one numbering for both forms - the
-- count of civil days since 1970-01-01, which is what the string form produces.
-- A numeric counter in a DIFFERENT numbering (a UTC day count, for instance) can
-- sit one day away from the string form, and the module cannot detect that: it
-- would roll the day over at the wrong hour and hand out an extra day's worth of
-- XP. The caller must therefore never mix the two forms - one numbering, one
-- source. The rollover itself is one-directional (see record_attempt), so a
-- backwards step can never wipe a day that was already earned.
local function day_ordinal(day)
if type(day) == "number" then
if day ~= day or day == math.huge or day == -math.huge then return nil end
return math.floor(day)
end
if type(day) ~= "string" then return nil end
local y, m, d = day:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
if not y then return nil end
y, m, d = tonumber(y), tonumber(m), tonumber(d)
if not y or not m or not d then return nil end
if m < 1 or m > 12 then return nil end
if d < 1 or d > days_in_month(y, m) then return nil end
return days_from_civil(y, m, d)
end
P.day_ordinal = day_ordinal
--=========================================================================================
-- Level curve
--=========================================================================================
-- The XP needed to advance from level n to level n+1: floor(100 * n^1.35).
function P.xp_for_level(level)
local n = count(level)
if n < 1 then n = 1 end
local value = P.LEVEL_CURVE_BASE * (n ^ P.LEVEL_CURVE_EXPONENT)
if value ~= value then return 0 end
if value == math.huge or value == -math.huge then return 0 end
if value > 2 ^ 53 then return 2 ^ 53 end
return math.floor(value)
end
-- The cumulative XP needed to reach a level from zero (the sum of the steps below it).
local LEVEL_SOLVE_MAX_STEPS = 100000 -- computational guard only, never reached by
-- real progression (level ~1400 needs ~1e9 XP); it stops a corrupt blob from hanging
-- the client.
local function cumulative_xp(level)
local target = count(level)
if target < 1 then return 0 end
local total = 0
local steps = 0
while steps < LEVEL_SOLVE_MAX_STEPS and steps < target - 1 do
total = total + P.xp_for_level(steps + 1)
steps = steps + 1
end
return total
end
-- The cumulative XP needed to reach a level from zero (public form).
function P.xp_total_for_level(level)
return cumulative_xp(level)
end
-- The level, renown and XP progress implied by an XP total. Past the level cap the
-- level stays at 50 and the renown counter takes over (spec §2 "Level cap").
function P.level_info(xp)
local total = count(xp)
local level = 1
local spent = 0
local steps = 0
while steps < LEVEL_SOLVE_MAX_STEPS do
local step = P.xp_for_level(level)
if step <= 0 then break end
if spent + step > total then break end
spent = spent + step
level = level + 1
steps = steps + 1
end
local raw = level
local display = raw
local renown = 0
if display > P.MAX_LEVEL then
renown = display - P.MAX_LEVEL
display = P.MAX_LEVEL
end
local need = P.xp_for_level(raw)
return {
level = display,
renown = renown,
raw_level = raw,
xp = total,
xp_into_level = total - spent,
xp_for_next_level = need,
xp_total_for_next = spent + need,
}
end
--=========================================================================================
-- Mastery per drill
--=========================================================================================
-- The median of a list of numbers: the middle value, or the mean of the two middle
-- values for an even count. The list is sorted in place.
local function median_of(values)
local n = #values
if n <= 0 then return nil end
table.sort(values)
if n % 2 == 1 then return values[(n + 1) / 2] end
return (values[n / 2] + values[n / 2 + 1]) / 2
end
-- The hit points of a drill's recent window: the grade points of every attempt that
-- was NOT a miss, with their median and their best.
--
-- H-1 (re-audit 2026-10-20): the cap used to be 3x the median over EVERY attempt in
-- the window. A miss earns nothing, so a drill below 50% accuracy had a median of 0
-- and a cap of 0 - and every award was swallowed for good (the player needed ~50
-- consecutive hits to escape, and the tier ladder is accuracy-gated too, so there
-- was no way out). The cap is now taken over the drill's HITS: a miss earns
-- nothing, so it must not drag the cap down.
--
-- nil when the window holds no hit at all - the first attempt of a drill cannot be
-- capped against a median that does not exist yet.
local function hit_points_window(recent)
if type(recent) ~= "table" then return nil end
local values = {}
for i = 1, #recent do
local grade = recent[i]
if is_hit(grade) then
local points = grade_points(grade)
if points > 0 then values[#values + 1] = points end
end
end
if #values <= 0 then return nil end
local median = median_of(values)
local best = values[#values] -- the list is sorted now
return { median = median, best = best }
end
-- The recency-weighted accuracy over a drill's recent window, in percent. The
-- weights decay exponentially with a 30-attempt half-life: the newest attempt counts
-- full, one 30 attempts old counts half (spec §2 "Mastery per drill").
function P.mastery_pct(recent)
if type(recent) ~= "table" then return 0 end
local n = #recent
if n <= 0 then return 0 end
local weighted_hits = 0
local weight_sum = 0
for i = 1, n do
local age = n - i -- 0 for the newest attempt
local weight = 0.5 ^ (age / P.MASTERY_HALF_LIFE)
weight_sum = weight_sum + weight
if is_hit(recent[i]) then weighted_hits = weighted_hits + weight end
end
if weight_sum <= 0 then return 0 end
return (weighted_hits / weight_sum) * 100
end
-- The tier a drill sits in. A drill needs BOTH the accuracy and the lifetime attempt
-- count of a tier; below the first one it reads Unranked (spec §2).
function P.tier_for(mastery_pct, attempts)
local pct = num(mastery_pct, 0)
local count_attempts = count(attempts)
local best = "unranked"
for name, tier in pairs(P.MASTERY_TIERS) do
if pct >= (tier.pct * 100) and count_attempts >= tier.attempts then
if (P.TIER_RANK[name] or 0) > (P.TIER_RANK[best] or 0) then
best = name
end
end
end
return best
end
-- The mastery read model of one drill: the weighted accuracy, the tier and the
-- volume gate. The gate is why one lucky S never grants mastery.
function P.mastery(drill)
if type(drill) ~= "table" then
return {
mastery_pct = 0,
tier = "unranked",
tier_name = P.TIER_NAMES.unranked,
ranked = false,
attempts = 0,
hits = 0,
}
end
local attempts = count(drill.attempts)
local hits = count(drill.hits)
if hits > attempts then hits = attempts end
local pct = P.mastery_pct(drill.recent)
local ranked = attempts >= P.MASTERY_MIN_ATTEMPTS
local tier = "unranked"
if ranked then tier = P.tier_for(pct, attempts) end
return {
mastery_pct = pct,
tier = tier,
tier_name = P.TIER_NAMES[tier] or P.TIER_NAMES.unranked,
ranked = ranked,
attempts = attempts,
hits = hits,
}
end
-- The daily XP cap of a drill: 3x the drill's median HIT points over the recent
-- window, with the best hit in that window as the floor (spec §2 "Anti-grind").
-- nil when the drill has no hit yet - the first attempt cannot be capped against a
-- median that does not exist yet.
--
-- The floor is what makes the guarantee hold: on a fresh day a hit is worth at
-- least its own points (the attempt being recorded is already in the window when
-- the cap is taken, so the floor is never below that attempt's grade points), and
-- the 3x median keeps the anti-grind - the drill cannot be farmed past three
-- times what it earns. The cap never exceeds 3x the best hit in the window.
--
-- The cap is read from the live window, so a low hit can lower it during a day;
-- the day's awards then stop. The day's total never exceeds the cap that was in
-- force when its award was made.
function P.drill_daily_cap(drill)
if type(drill) ~= "table" then return nil end
local window = hit_points_window(drill.recent)
if window == nil then return nil end
local cap = math.floor(window.median * P.DAILY_CAP_MULTIPLIER)
if cap < window.best then cap = window.best end
return cap
end
--=========================================================================================
-- State
--=========================================================================================
-- A fresh drill record: lifetime counters, the recent-grade window that feeds
-- mastery and the median, and today's capped XP.
local function new_drill()
return {
attempts = 0,
hits = 0,
recent = {},
daily_xp = 0,
daily_day = nil,
}
end
-- A fresh class scope: the class's XP, its streaks, its drills and its unlocks.
local function new_class_scope()
return {
xp = 0,
best_streak = 0,
daily_streak = 0,
last_session_day = nil,
drills = {},
unlocks = {},
}
end
-- A fresh progress state: one scope per class (created on demand) plus the account
-- scope, which carries the account-wide day streak and the account-wide unlock flags.
function P.new_state()
return {
version = P.STATE_VERSION,
classes = {},
global = {
daily_streak = 0,
last_session_day = nil,
unlocks = {},
},
}
end
-- A fresh session state: the live streak and the XP earned in this session. The
-- session is owned by the caller (one per practice session).
--
-- bonus_xp is the completion bonus this session has already been paid (M3): the
-- bonus is a share of the session's awarded XP, so the paid amount is what the
-- next end_session has to subtract from the session's XP to find the attempt XP.
function P.new_session()
return {
attempts = 0,
hits = 0,
streak = 0,
best_streak = 0,
xp = 0,
bonus_xp = 0,
}
end
-- The class scope for a class key, created on demand. nil when the key is unusable.
local function class_scope(progress, class_key)
if type(class_key) ~= "string" or class_key == "" then return nil end
if type(progress.classes) ~= "table" then progress.classes = {} end
local scope = progress.classes[class_key]
if type(scope) ~= "table" then
scope = new_class_scope()
progress.classes[class_key] = scope
end
return scope
end
-- The scope a call reads and writes: a class scope, or the account scope.
-- create = false keeps a read from adding an empty scope to the state.
local function scope_of(progress, scope_key, create)
if type(progress) ~= "table" then return nil, false end
if scope_key == P.GLOBAL_SCOPE then
if type(progress.global) ~= "table" then
if not create then return nil, true end
progress.global = { daily_streak = 0, last_session_day = nil, unlocks = {} }
end
return progress.global, true
end
if create == false then
local scope = (type(progress.classes) == "table") and progress.classes[scope_key] or nil
if type(scope) ~= "table" then return nil, false end
return scope, false
end
return class_scope(progress, scope_key), false
end
-- The XP of a scope: a class's own XP, or the account total (the sum over the
-- classes - the account XP is derived, never stored twice).
function P.scope_xp(progress, scope_key)
if type(progress) ~= "table" then return 0 end
if scope_key == P.GLOBAL_SCOPE then
local total = 0
if type(progress.classes) == "table" then
for _, scope in pairs(progress.classes) do
if type(scope) == "table" then total = total + count(scope.xp) end
end
end
return total
end
local scope = scope_of(progress, scope_key, false)
return scope and count(scope.xp) or 0
end
-- The lifetime best session streak of a scope: a class's own, or the account's
-- best over the classes.
function P.scope_best_streak(progress, scope_key)
if type(progress) ~= "table" then return 0 end
if scope_key == P.GLOBAL_SCOPE then
local best = 0
if type(progress.classes) == "table" then
for _, scope in pairs(progress.classes) do
if type(scope) == "table" then
local value = count(scope.best_streak)
if value > best then best = value end
end
end
end
return best
end
local scope = scope_of(progress, scope_key, false)
return scope and count(scope.best_streak) or 0
end
-- The daily streak of a scope (consecutive calendar days with a completed session).
function P.scope_daily_streak(progress, scope_key)
if type(progress) ~= "table" then return 0 end
local scope = scope_of(progress, scope_key, false)
if not scope then return 0 end
return count(scope.daily_streak)
end
--=========================================================================================
-- Streaks
--=========================================================================================
-- Advance a scope's daily streak for a session that ended on the given day.
--
-- The streak counts consecutive calendar days with at least one completed session.
-- A missed day does NOT reset it - it simply stops the increment (spec §2 "Streaks":
-- no punishment for a rest day), so the reference day moves forward and the next
-- consecutive day counts again.
local function bump_daily_streak(scope, ordinal)
if type(scope) ~= "table" or ordinal == nil then return false end
local last = scope.last_session_day
if type(last) ~= "number" then
scope.daily_streak = 1
scope.last_session_day = ordinal
return true
end
if ordinal == last then
return false -- already counted today
end
if ordinal == last + 1 then
scope.daily_streak = count(scope.daily_streak) + 1
scope.last_session_day = ordinal
return true
end
-- A gap: no increment, no reset.
scope.last_session_day = ordinal
return false
end
--=========================================================================================
-- Recording
--=========================================================================================
-- Record one graded attempt: XP (after the daily per-drill cap), the session streak,
-- the drill's counters and its recent-grade window.
--
-- A grade outside the scoring model's set is not an attempt and changes nothing. An
-- F is an attempt: it earns nothing and it breaks the streak.
--
-- progress and session are both mutated in place; the progress state is returned for
-- convenience.
function P.record_attempt(progress, session, class_key, drill_id, grade, day)
if type(progress) ~= "table" then return progress end
if not P.known_grade(grade) then return progress end
local scope = class_scope(progress, class_key)
if not scope then return progress end
if type(session) ~= "table" then session = P.new_session() end
local hit = is_hit(grade)
local points = grade_points(grade)
session.attempts = count(session.attempts) + 1
if hit then session.hits = count(session.hits) + 1 end
if hit then
session.streak = count(session.streak) + 1
else
session.streak = 0
end
if count(session.streak) > count(session.best_streak) then
session.best_streak = count(session.streak)
end
if count(session.streak) > count(scope.best_streak) then
scope.best_streak = count(session.streak)
end
-- The drill record: lifetime counters plus the recent-grade window. An attempt
-- with no usable drill id lands in the reserved "_unknown" bucket, so its XP is
-- still tracked and still capped (a caller cannot dodge the anti-grind cap by
-- dropping the drill id).
if type(scope.drills) ~= "table" then scope.drills = {} end
local key = (type(drill_id) == "string" and drill_id ~= "") and drill_id or "_unknown"
local drill = scope.drills[key]
if type(drill) ~= "table" then
drill = new_drill()
scope.drills[key] = drill
end
drill.attempts = count(drill.attempts) + 1
if hit then drill.hits = count(drill.hits) + 1 end
local recent = drill.recent
if type(recent) ~= "table" then recent = {}; drill.recent = recent end
recent[#recent + 1] = grade
while #recent > P.MASTERY_WINDOW do table.remove(recent, 1) end
-- The daily cap: the day's earned XP resets on a new calendar day, and the cap
-- is 3x the drill's median HIT points over the window above.
--
-- L-6 (re-audit 2026-10-20): the rollover used to fire on ANY difference
-- between the stored day and the new one, so a BACKWARDS clock (a client whose
-- calendar steps back, or a caller that mixes two day numberings) wiped the
-- day's XP and handed the player a free extra day. The rollover is now
-- ONE-DIRECTIONAL: only a day strictly later than the stored one starts a new
-- day; an earlier or the same day leaves the day's XP alone.
local ordinal = day_ordinal(day)
local stored = drill.daily_day
if ordinal ~= nil and (type(stored) ~= "number" or ordinal > stored) then
drill.daily_xp = 0
drill.daily_day = ordinal
end
local awarded = points
local cap = P.drill_daily_cap(drill)
if cap ~= nil then
local room = cap - count(drill.daily_xp)
if room <= 0 then
awarded = 0
elseif awarded > room then
awarded = room
end
end
drill.daily_xp = count(drill.daily_xp) + awarded
scope.xp = count(scope.xp) + awarded
session.xp = count(session.xp) + awarded
return progress
end
-- End a session: the completion bonus, the daily streak of the class scope and of
-- the account scope, and the level the class landed on.
--
-- opts.completion_bonus_factor scales the +25 completion bonus (spec §2); the
-- default is the identity, the flat +25 the spec writes.
function P.end_session(progress, session, class_key, day, opts)
opts = (type(opts) == "table") and opts or {}
local result = {
attempts = 0,
xp_earned = 0,
bonus_xp = 0,
leveled_up = false,
level = 1,
renown = 0,
daily_streak = 0,
}
if type(progress) ~= "table" then return result end
local scope = class_scope(progress, class_key)
if not scope then return result end
if type(session) ~= "table" then session = P.new_session() end
local attempts = count(session.attempts)
result.attempts = attempts
if attempts <= 0 then
-- Nothing was practised: no bonus, and the day streak does not move.
result.level = P.level_info(P.scope_xp(progress, class_key)).level
return result
end
local before = P.level_info(P.scope_xp(progress, class_key))
-- Completion bonus: rewards finishing a session, not grinding it.
--
-- M3 (re-audit 2026-10-20): the bonus used to be a flat +25 for a session with
-- ten ATTEMPTS, and it was added straight to the scope's XP - outside the daily
-- per-drill cap, so it was pure profit on top of a capped day. Ten misses (ten F
-- grades, zero attempt XP) earned the full bonus.
--
-- The bonus is now a SHARE of what the session actually EARNED through the
-- capped attempt path: never more than the flat +25 the spec writes and never
-- more than the share of the session's awarded XP. A session that earned
-- nothing earns no bonus, and because the share comes out of ALREADY capped
-- XP, the daily cap still bounds the total. The bonus is also paid ONCE per
-- session: the paid amount is remembered on the session, so a second
-- end_session on the same session pays nothing more.
if attempts >= P.SESSION_BONUS_MIN_ATTEMPTS and count(session.bonus_xp) == 0 then
local factor = num(opts.completion_bonus_factor, P.COMPLETION_BONUS_FACTOR)
if factor < 0 then factor = 0 end
local earned = count(session.xp)
local bonus = math.floor(P.SESSION_BONUS_XP * factor)
local share = math.floor(earned * P.SESSION_BONUS_XP_SHARE)
if share < bonus then bonus = share end
if bonus > 0 then
scope.xp = count(scope.xp) + bonus
session.xp = count(session.xp) + bonus
session.bonus_xp = bonus
result.bonus_xp = bonus
end
end
-- The daily streak: the class scope and the account scope each track their own
-- consecutive-day run.
local ordinal = day_ordinal(day)
bump_daily_streak(scope, ordinal)
if type(progress.global) ~= "table" then
progress.global = { daily_streak = 0, last_session_day = nil, unlocks = {} }
end
bump_daily_streak(progress.global, ordinal)
local after = P.level_info(P.scope_xp(progress, class_key))
result.xp_earned = count(session.xp)
result.level = after.level
result.renown = after.renown
result.leveled_up = (after.raw_level > before.raw_level)
result.daily_streak = count(scope.daily_streak)
return result
end
--=========================================================================================
-- The generic unlock registry
--=========================================================================================
--
-- The registry maps an unlock id to its definition: a requirement, a kind and the
-- unlock state. The spec's §4 achievements are the "achievement" kind; the operator
-- order of 2026-10-19 adds the "cosmetic" kind (themes, popup styles, titles) and
-- the "drill_variant" kind (level-gated harder variants).
--
-- HARD RULE (operator order): an unlock NEVER changes how an existing drill behaves.
-- A drill variant is registered as its OWN scenario by the practice layer; this
-- module only records that the variant is unlocked. There is no grading, timing or
-- sim code here at all, and a drill variant names the new scenario it grants - it
-- never points at an existing drill to modify.
--
-- Requirement forms:
-- { type = "level", level = N } the scope's level must be at least N
-- { type = "achievement", id = "..." } that unlock id must already be unlocked
-- { type = "predicate", test = fn } fn(snapshot) must return a truthy value
-- An unknown or malformed requirement is never met: an unlock the module cannot
-- understand stays locked rather than firing by accident.
--
-- THE SCOPE CONTRACT (M-2, re-audit 2026-10-20): the unlock flags live in the
-- scope they were earned in - a class scope (progress.classes[key].unlocks) or the
-- account scope (progress.global.unlocks). A definition with
-- scope = "class" is only ever evaluated in a class scope, one with
-- scope = "account" only in the account scope, and one with NO scope in BOTH.
--
-- The consequence a caller must honour: an account-scoped achievement can only
-- fire when the ACCOUNT scope is evaluated, and the account scope is a scope of
-- its own. Evaluating only the class scope leaves every account-scoped
-- achievement locked for good (and the account model reports none of them).
-- P.evaluate_unlocks_both does both evaluations and merges the two results - the
-- session-end path uses it.
--
-- The registry: id -> definition. Later lanes add to it through register_unlock.
P.UNLOCKS = {}
-- Register an unlock definition. Returns the stored definition, or nil when the
-- input is unusable.
--
-- spec = {
-- kind = "cosmetic" | "drill_variant" | "achievement",
-- label = "display text",
-- requirement = <requirement table>,
-- scenario = "the NEW scenario key" (drill_variant only),
-- scope = "class" | "account" (optional; both by default),
-- }
function P.register_unlock(id, spec)
if type(id) ~= "string" or id == "" then return nil end
if type(spec) ~= "table" then return nil end
local kind = spec.kind
if kind ~= P.KIND_ACHIEVEMENT and kind ~= P.KIND_COSMETIC and kind ~= P.KIND_DRILL_VARIANT then
-- L-1 (re-audit 2026-10-20): an unknown kind is REJECTED, not coerced. A
-- typo'd kind used to be stored as a cosmetic, so the definition was
-- accepted and then granted nothing - a silent dead unlock. A missing kind
-- is rejected the same way: the kind is not optional.
return nil
end
local scenario = nil
if kind == P.KIND_DRILL_VARIANT then
-- A variant must name the scenario it grants. Without it the definition is
-- unusable, so it is rejected rather than stored half-formed.
if type(spec.scenario) ~= "string" or spec.scenario == "" then return nil end
scenario = spec.scenario
end
local scope = spec.scope
if scope ~= "class" and scope ~= "account" then scope = nil end
-- The requirement is COPIED, never aliased: the registry must not hold a
-- reference into a live table (the hard rule above - an unlock never reaches
-- into a drill's own data). A later change to the caller's table cannot
-- rewrite a registered requirement behind the registry's back.
local requirement = nil
if type(spec.requirement) == "table" then
requirement = {}
for key, value in pairs(spec.requirement) do
requirement[key] = value
end
end
local definition = {
id = id,
kind = kind,
label = (type(spec.label) == "string") and spec.label or id,
requirement = requirement,
scenario = scenario,
scope = scope,
}
P.UNLOCKS[id] = definition
return definition
end
-- The registered definitions of one kind (all kinds when kind is nil), sorted by id
-- so the UI can render a stable list.
function P.unlock_definitions(kind)
local out = {}
for _, definition in pairs(P.UNLOCKS) do
if type(definition) == "table" and (kind == nil or definition.kind == kind) then
out[#out + 1] = definition
end
end
table.sort(out, function(a, b) return (a.id or "") < (b.id or "") end)
return out
end
-- The scenario key an unlock grants, or nil. Only the drill-variant kind names a
-- scenario: a level-gated harder variant is a NEW scenario (the audit's §4.3
-- requirement - "a drill variant names the new scenario it grants"), never a
-- change to an existing drill. The key is the practice layer's own registry key
-- (the same key space the class drills and the rotation scenarios use), so the
-- unlock lane can point at a drill or a scenario without a second namespace.
function P.unlock_scenario(id)
if type(id) ~= "string" or id == "" then
return nil
end
local definition = P.UNLOCKS[id]
if type(definition) ~= "table" then
return nil
end
if definition.kind ~= P.KIND_DRILL_VARIANT then
return nil
end
return definition.scenario
end
-- Is a requirement met by a snapshot? See the requirement forms above.
local function requirement_met(requirement, snapshot)
if type(requirement) ~= "table" then return false end
local kind = requirement.type
if kind == "level" then
local level = num(requirement.level, nil)
if level == nil then return false end
local current = num(snapshot and snapshot.level, 0) or 0
return current >= level
end
if kind == "achievement" then
local id = requirement.id
if type(id) ~= "string" then return false end
local unlocked = snapshot and snapshot.unlocks
if type(unlocked) ~= "table" then return false end
return unlocked[id] == true
end
if kind == "predicate" then
if type(requirement.test) ~= "function" then return false end
local ok, passed = pcall(requirement.test, snapshot)
if not ok then return false end
return passed and true or false
end
return false
end
-- Evaluate the registry against one scope's snapshot and unlock whatever the scope
-- has earned. The unlock flags live in the scope, so a class-level unlock never
-- leaks into another class.
--
-- Returns { newly = {ids}, unlocked = {ids} } - "newly" is what the toast / chat
-- notice lane fires on, "unlocked" is the full set for the scope.
function P.evaluate_unlocks(progress, snapshot, scope_key)
local out = { newly = {}, unlocked = {} }
if type(progress) ~= "table" then return out end
local scope, is_account = scope_of(progress, scope_key, true)
if not scope then return out end
if type(scope.unlocks) ~= "table" then scope.unlocks = {} end
local ids = {}
for id in pairs(P.UNLOCKS) do ids[#ids + 1] = id end
table.sort(ids)
-- The evaluation reads the scope's LIVE unlock flags, not the snapshot's copy:
-- an achievement-gated unlock must see an achievement unlocked earlier in this
-- same pass. The caller's snapshot is not mutated - a shallow copy carries the
-- live map instead.
local eval_snapshot = snapshot
if type(snapshot) == "table" then
eval_snapshot = {}
for key, value in pairs(snapshot) do eval_snapshot[key] = value end
elseif snapshot == nil then
eval_snapshot = {}
end
eval_snapshot.unlocks = scope.unlocks
for _, id in ipairs(ids) do
local definition = P.UNLOCKS[id]
if type(definition) == "table" then
-- A scope-restricted unlock is only evaluated in its own kind of scope.
local allowed = true
if definition.scope == "class" then allowed = not is_account end
if definition.scope == "account" then allowed = is_account end
if allowed then
local unlocked = scope.unlocks[id] == true
if not unlocked and requirement_met(definition.requirement, eval_snapshot) then
scope.unlocks[id] = true
unlocked = true
out.newly[#out.newly + 1] = id
end
if unlocked then out.unlocked[id] = true end
end
end
end
table.sort(out.newly)
return out
end
-- Evaluate the registry in the CLASS scope and in the ACCOUNT scope and merge the
-- two results.
--
-- M-2 (re-audit 2026-10-20): the production path evaluated the class scope only,
-- so progress.global.unlocks was never written and every account-scoped
-- achievement could never fire - the account model reported none of them. The two
-- scopes carry separate flags, so the account scope needs its own evaluation.
--
-- class_snapshot is the read model of the class scope, account_snapshot the read
-- model of the account scope (both from P.profile_snapshot). The account