Files
scrabble-game/backend/internal/robot/strategy_test.go
T
Ilia Denisov f3768d20f2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
feat(robot): shrink endgame think time when both sides pass
In a dead-drawn endgame — the two most recent journal moves are both
passes, so the board and the robot's rack are frozen and the robot is
bound to pass again — the robot still waited out its long late-game think
time (up to 90 min) before passing, needlessly dragging out a decided game.

Shorten that delay to a [0.8, 1.5]x band around the human's last-move think
time (the gap between the last two journal entries), clamped to [30s, 8min]
and taken as a min with the normal schedule, so the robot never moves
slower. A slow human collapses to the 8-min cap; a fast human is tracked,
with the floor keeping the robot from passing suspiciously instantly. The
anchor reads the move journal only (no schema change), stays deterministic
from the seed, and still defers to the sleep window.

RobotTurns now carries EndgamePass + OppLastMove, filled by one batched
journal query on the scan; the honest-AI single-game trigger keeps the
normal path (it moves at once). NextMoveAt (admin ETA) is left as the
normal-schedule upper bound.
2026-06-19 12:48:39 +02:00

406 lines
15 KiB
Go

package robot
import (
"math"
"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)
}
}
// TestDeviateProbTaper checks the deviation probability is deviateMaxProb while the
// bag holds at least deviateTaperTiles tiles, halves at the taper midpoint, is 0
// once the bag is empty, and stays within [0, deviateMaxProb] and non-decreasing.
func TestDeviateProbTaper(t *testing.T) {
if p := deviateProb(0); p != 0 {
t.Errorf("deviateProb(0) = %v, want 0 (strict endgame)", p)
}
if p := deviateProb(deviateTaperTiles); p != deviateMaxProb {
t.Errorf("deviateProb(%d) = %v, want %v", deviateTaperTiles, p, deviateMaxProb)
}
if p := deviateProb(deviateTaperTiles + 50); p != deviateMaxProb {
t.Errorf("deviateProb above the taper = %v, want %v (capped)", p, deviateMaxProb)
}
if p := deviateProb(deviateTaperTiles / 2); math.Abs(p-deviateMaxProb/2) > 1e-9 {
t.Errorf("deviateProb at half taper = %v, want ~%v", p, deviateMaxProb/2)
}
prev := -1.0
for bag := 0; bag <= deviateTaperTiles+5; bag++ {
p := deviateProb(bag)
if p < 0 || p > deviateMaxProb {
t.Fatalf("deviateProb(%d) = %v out of [0,%v]", bag, p, deviateMaxProb)
}
if p < prev {
t.Fatalf("deviateProb not non-decreasing: bag %d gives %v after %v", bag, p, prev)
}
prev = p
}
}
// TestDeviatesNeverInEndgame checks the robot never deviates once the bag is empty,
// for every seed and move count, so the endgame follows the chosen strategy strictly.
func TestDeviatesNeverInEndgame(t *testing.T) {
for seed := int64(1); seed <= 5000; seed++ {
for mc := 0; mc < 40; mc++ {
if deviates(seed, mc, 0) {
t.Fatalf("deviates with an empty bag for seed=%d mc=%d", seed, mc)
}
}
}
}
// TestDeviatesDeterministic checks the per-turn deviation draw is reproducible for a
// (seed, moveCount, bagLen), so the driver recomputes the same decision on every scan.
func TestDeviatesDeterministic(t *testing.T) {
for seed := int64(1); seed <= 500; seed++ {
for mc := 0; mc < 30; mc++ {
got := deviates(seed, mc, deviateTaperTiles)
if deviates(seed, mc, deviateTaperTiles) != got {
t.Fatalf("deviates not deterministic for seed=%d mc=%d", seed, mc)
}
}
}
}
// TestDeviatesDistribution checks the deviation rate over many games lands near
// deviateMaxProb while the bag is full (above the taper), at a fixed move count.
func TestDeviatesDistribution(t *testing.T) {
const n = 20000
hits := 0
for seed := int64(1); seed <= n; seed++ {
if deviates(seed, 3, deviateTaperTiles+20) {
hits++
}
}
pct := float64(hits) / float64(n) * 100
want := deviateMaxProb * 100
if pct < want-2 || pct > want+2 {
t.Errorf("deviation rate = %.1f%%, want ~%.0f%% (±2)", pct, want)
}
}
// TestProactiveNudgeGap checks the proactive-nudge schedule: the first gap (refIdle 0) is
// ~60-90 min, every gap stays within [60 min, 6 h] and is deterministic, and the gap lengthens
// as the idle grows (the median at 12 h idle exceeds the median at the start).
func TestProactiveNudgeGap(t *testing.T) {
for seed := int64(1); seed <= 1000; seed++ {
if first := proactiveNudgeGap(0, seed); first < 60*time.Minute || first > 90*time.Minute {
t.Fatalf("first gap %s out of [60m,90m] for seed %d", first, seed)
}
for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} {
g := proactiveNudgeGap(idle, seed)
if g < 60*time.Minute || g > 6*time.Hour {
t.Fatalf("gap %s out of [60m,6h] for seed %d idle %s", g, seed, idle)
}
if proactiveNudgeGap(idle, seed) != g {
t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle)
}
}
}
median := func(idle time.Duration) float64 {
const n = 4000
xs := make([]float64, n)
for s := 0; s < n; s++ {
xs[s] = proactiveNudgeGap(idle, int64(s+1)).Minutes()
}
sort.Float64s(xs)
return xs[n/2]
}
if early, late := median(0), median(12*time.Hour); early >= late {
t.Errorf("median gap should grow with idle: idle0=%.0f idle12h=%.0f", early, late)
}
}
// TestEndgamePassDelayBoundsAndAnchor checks the shortened endgame think time: it always
// lands in [30s, 8min], collapses a slow human to the cap, floors a fast human, tracks a
// mid human inside [0.8,1.5]*oppLast, floors a clock-skew negative gap, and is
// deterministic per (seed, moveCount).
func TestEndgamePassDelayBoundsAndAnchor(t *testing.T) {
const floor = 30 * time.Second
const ceil = 8 * time.Minute
cases := []struct {
name string
oppLast time.Duration
lo, hi time.Duration // expected inclusive output range
}{
{"clock-skew negative floors", -time.Hour, floor, floor},
{"zero floors", 0, floor, floor},
{"very fast floors", 3 * time.Second, floor, floor}, // [2.4s,4.5s] → floor
{"fast tracks above floor", 30 * time.Second, floor, 45 * time.Second}, // [24s,45s] → [30s,45s]
{"mid tracks in band", 2 * time.Minute, 96 * time.Second, 3 * time.Minute}, // [1.6m,3m]
{"at cap boundary", 8 * time.Minute, 384 * time.Second, ceil}, // [6.4m,12m] → [6.4m,8m]
{"slow caps", 3 * time.Hour, ceil, ceil}, // [2.4h,4.5h] → cap
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
for seed := int64(1); seed <= 2000; seed++ {
d := endgamePassDelay(seed, 30, c.oppLast)
if d < floor || d > ceil {
t.Fatalf("oppLast=%s seed=%d: delay %s out of hard [%s,%s]", c.oppLast, seed, d, floor, ceil)
}
if d < c.lo || d > c.hi {
t.Fatalf("oppLast=%s seed=%d: delay %s out of expected [%s,%s]", c.oppLast, seed, d, c.lo, c.hi)
}
if endgamePassDelay(seed, 30, c.oppLast) != d {
t.Fatalf("oppLast=%s seed=%d: not deterministic", c.oppLast, seed)
}
}
})
}
}
// TestEndgamePassDelayShrinksLateGame checks the endgame think time is always shorter than
// the normal late-game schedule (band floor 10min vs the 8min cap), so taking the min in the
// driver actually speeds the robot up rather than ever slowing it down.
func TestEndgamePassDelayShrinksLateGame(t *testing.T) {
for seed := int64(1); seed <= 1000; seed++ {
for mc := 28; mc <= 40; mc++ {
eg := endgamePassDelay(seed, mc, 3*time.Hour) // worst case: a slow human, caps at 8min
if nd := moveDelay(seed, mc); eg >= nd {
t.Fatalf("seed=%d mc=%d: endgame %s not shorter than normal %s", seed, mc, eg, nd)
}
}
}
}
// 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
}