Stage 17: backend defect fixes (nudge code, TG name, robot names/timing, multi-device push, move-duration metric + admin analytics)

- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn')
- #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback
- #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin)
- #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4)
- #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only
- #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal)
- ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
This commit is contained in:
Ilia Denisov
2026-06-06 09:59:12 +02:00
parent 6886efb6c0
commit 635f2fd9fc
30 changed files with 1068 additions and 120 deletions
+37 -10
View File
@@ -27,14 +27,14 @@ func TestPlayToWinDistribution(t *testing.T) {
}
}
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in
// [2min, 90min) and is reproducible for a (seed, moveCount).
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard
// bounds [1min, 90min] and is reproducible for a (seed, moveCount).
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
for seed := int64(1); seed <= 200; seed++ {
for mc := 0; mc < 50; mc++ {
d := moveDelay(seed, mc)
if d < 2*time.Minute || d >= 90*time.Minute {
t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc)
if d < 1*time.Minute || d > 90*time.Minute {
t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc)
}
if moveDelay(seed, mc) != d {
t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
@@ -43,22 +43,49 @@ func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
}
}
// TestMoveDelaySkew checks the distribution is right-skewed with the intended
// ~10-minute median: most delays are short, the mean sits above the median.
// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the
// first move lives in the short [1,5]min band, a late move in the long [10,90]min
// band, so the median think time rises with the move count.
func TestMoveDelayGrowsWithMoveCount(t *testing.T) {
median := func(mc int) float64 {
const n = 4000
xs := make([]float64, n)
for s := 0; s < n; s++ {
xs[s] = moveDelay(int64(s+1), mc).Minutes()
}
sort.Float64s(xs)
return xs[n/2]
}
for s := int64(1); s <= 500; s++ {
if d := moveDelay(s, 0).Minutes(); d < 1 || d > 5 {
t.Fatalf("first-move delay %.2f out of [1,5] for seed %d", d, s)
}
if d := moveDelay(s, 40).Minutes(); d < 10 || d > 90 {
t.Fatalf("late-move delay %.2f out of [10,90] for seed %d", d, s)
}
}
if early, late := median(0), median(30); early >= late {
t.Errorf("median should grow with move count: move0=%.1f move30=%.1f", early, late)
}
}
// TestMoveDelaySkew checks the late-game distribution is right-skewed at a fixed move
// count: short delays are frequent (median near the band floor) and the mean sits
// above the median, with a tail toward the cap.
func TestMoveDelaySkew(t *testing.T) {
const n = 20000
mins := make([]float64, 0, n)
var sum float64
for mc := 0; mc < n; mc++ {
m := moveDelay(42, mc).Minutes()
for s := 0; s < n; s++ {
m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90]
mins = append(mins, m)
sum += m
}
sort.Float64s(mins)
median := mins[n/2]
mean := sum / float64(n)
if median < 7 || median > 13 {
t.Errorf("median delay = %.1f min, want ~10 (7-13)", median)
if median < 12 || median > 20 {
t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median)
}
if mean <= median {
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)