package robot import ( "sort" "testing" "time" "scrabble/backend/internal/engine" ) // TestPlayToWinDistribution checks the once-per-game decision is fixed per seed // and lands near the 40% target over many games. func TestPlayToWinDistribution(t *testing.T) { const n = 20000 wins := 0 for seed := int64(1); seed <= n; seed++ { if playToWin(seed) { wins++ } if playToWin(seed) != playToWin(seed) { t.Fatalf("playToWin not deterministic for seed %d", seed) } } pct := float64(wins) / float64(n) * 100 if pct < 37 || pct > 43 { t.Errorf("play-to-win rate = %.1f%%, want ~40%% (37-43)", pct) } } // 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 < 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) } } } } // 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 < 3 || d > 10 { t.Fatalf("first-move delay %.2f out of [3,10] 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 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 < 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) } } // TestSelectMovePlayToWinKeepsLeadSmall checks the winning robot prefers an // in-band move with the smallest resulting lead. func TestSelectMovePlayToWinKeepsLeadSmall(t *testing.T) { cands := plays(50, 20, 5, 2) // margins 50,20,5,2 with scores even d := selectMove(cands, 100, 100, true, marginBand{1, 30}, nil, 0) if d.kind != decidePlay || d.move.Score != 2 { t.Errorf("got kind=%d score=%d, want play score=2 (smallest in-band lead)", d.kind, d.move.Score) } } // TestSelectMovePlayToLoseKeepsDeficitSmall checks the losing robot prefers the // in-band move with the smallest deficit. func TestSelectMovePlayToLoseKeepsDeficitSmall(t *testing.T) { cands := plays(50, 20, 15, 5) // myScore 80, opp 100 → margins 30,0,-5,-15 d := selectMove(cands, 80, 100, false, marginBand{1, 30}, nil, 0) if d.kind != decidePlay || d.move.Score != 15 { t.Errorf("got kind=%d score=%d, want play score=15 (smallest deficit in band)", d.kind, d.move.Score) } } // TestSelectMoveFallbackBehind checks that when even the best play cannot reach // the band the winning robot takes the highest-scoring move (best catch-up). func TestSelectMoveFallbackBehind(t *testing.T) { cands := plays(10, 5) // myScore 50, opp 100 → margins -40,-45, both below band d := selectMove(cands, 50, 100, true, marginBand{1, 30}, nil, 0) if d.move.Score != 10 { t.Errorf("got score=%d, want 10 (closest to band from below)", d.move.Score) } } // TestSelectMoveFallbackOvershoot checks that when every play overshoots the band // the winning robot takes the lowest-scoring move (keeps the lead near the cap). func TestSelectMoveFallbackOvershoot(t *testing.T) { cands := plays(40, 10) // myScore 100, opp 50 → margins 90,60, both above band d := selectMove(cands, 100, 50, true, marginBand{1, 30}, nil, 0) if d.move.Score != 10 { t.Errorf("got score=%d, want 10 (closest to band from above)", d.move.Score) } } // TestSelectMoveNoPlay checks the exchange-or-pass fallback. func TestSelectMoveNoPlay(t *testing.T) { rack := []string{"A", "B", "C"} if d := selectMove(nil, 0, 0, true, defaultBand, rack, 5); d.kind != decideExchange || len(d.exchange) != 3 { t.Errorf("with a refillable bag want exchange of 3, got kind=%d n=%d", d.kind, len(d.exchange)) } if d := selectMove(nil, 0, 0, true, defaultBand, rack, 2); d.kind != decidePass { t.Errorf("with a short bag want pass, got kind=%d", d.kind) } if d := selectMove(nil, 0, 0, true, defaultBand, nil, 9); d.kind != decidePass { t.Errorf("with an empty rack want pass, got kind=%d", d.kind) } } // TestSleepDriftBounds checks the drift stays within ±3h and is deterministic. func TestSleepDriftBounds(t *testing.T) { for seed := int64(1); seed <= 5000; seed++ { d := sleepDrift(seed) if d < -3*time.Hour || d > 3*time.Hour { t.Fatalf("drift %s out of ±3h for seed %d", d, seed) } if sleepDrift(seed) != d { t.Fatalf("drift not deterministic for seed %d", seed) } } } // TestAsleep covers the window, the drift shift, a real timezone and the // midnight wrap. func TestAsleep(t *testing.T) { at := func(tz string, y int, mo time.Month, d, h int) time.Time { loc, err := time.LoadLocation(tz) if err != nil { t.Fatalf("load %s: %v", tz, err) } return time.Date(y, mo, d, h, 0, 0, 0, loc) } cases := []struct { name string tz string drift time.Duration now time.Time want bool }{ {"utc night", "UTC", 0, at("UTC", 2024, 1, 1, 3), true}, {"utc day", "UTC", 0, at("UTC", 2024, 1, 1, 12), false}, {"utc edge end", "UTC", 0, at("UTC", 2024, 1, 1, 7), false}, {"drift+3 shifts earlier", "UTC", 3 * time.Hour, at("UTC", 2024, 1, 1, 22), true}, {"drift+3 awake midday", "UTC", 3 * time.Hour, at("UTC", 2024, 1, 1, 5), false}, {"drift-3 shifts later", "UTC", -3 * time.Hour, at("UTC", 2024, 1, 1, 9), true}, {"tokyo asleep", "Asia/Tokyo", 0, at("UTC", 2024, 1, 1, 18), true}, // 03:00 JST {"tokyo awake", "Asia/Tokyo", 0, at("UTC", 2024, 1, 1, 0), false}, // 09:00 JST {"bad tz falls back to utc", "Nowhere/Bad", 0, at("UTC", 2024, 1, 1, 3), true}, } for _, c := range cases { if got := asleep(c.tz, c.drift, c.now); got != c.want { t.Errorf("%s: asleep = %v, want %v", c.name, got, c.want) } } } // TestMixDeterministic checks the mixer is stable (across calls, and so across // restarts) and salt-sensitive. func TestMixDeterministic(t *testing.T) { if mix(7, "win") != mix(7, "win") { t.Error("mix not stable for the same inputs") } if mix(7, "win") == mix(7, "delay") { t.Error("mix should differ by salt") } if mix(7, "delay", 1) == mix(7, "delay", 2) { t.Error("mix should differ by move index") } } // TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never // earlier than the sampled think-time delay, and it never lands while the robot is asleep // (a delay that would fall in the sleep window is deferred to the wake time). func TestNextMoveAt(t *testing.T) { base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for seed := int64(1); seed <= 500; seed++ { for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day start := base.Add(time.Duration(h) * time.Hour) at := NextMoveAt(seed, 3, start, "UTC") if at.Before(start.Add(moveDelay(seed, 3))) { t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at) } if asleep("UTC", sleepDrift(seed), at) { t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at) } } } } // TestPlayToWinExport checks the exported decision matches the internal one and the target. func TestPlayToWinExport(t *testing.T) { for seed := int64(1); seed <= 200; seed++ { if PlayToWin(seed) != playToWin(seed) { t.Fatalf("PlayToWin(%d) != playToWin", seed) } } if PlayToWinTargetPercent != playToWinPercent { t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent) } } // plays builds candidate plays carrying only the given scores (ranked as passed). func plays(scores ...int) []engine.MoveRecord { out := make([]engine.MoveRecord, len(scores)) for i, s := range scores { out[i] = engine.MoveRecord{Action: engine.ActionPlay, Score: s} } return out }