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
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.
406 lines
15 KiB
Go
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
|
|
}
|