bf7dca0a09
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Two owner-reported defects from a live contour game. A. Frequency: the robot's proactive nudge fired hourly for 12h+ (a 12h idle threshold then the 1h cooldown, uncapped). Replaced with a lengthening, randomized schedule (proactiveNudgeGap): the first nudge ~60-90 min into the human's turn, each later gap growing toward 1-6h (uniform sample in [60min, ceil], ceil ramping 90min->6h over 12h of idle, measured from the previous nudge), so a long wait gets a handful of increasingly-spaced reminders instead of a stream. B. Language: out-of-app push routed by the recipient's GLOBAL service_language (last-login-wins), so after re-logging via the RU bot an English game's nudges came from the RU bot. Now a game push (your_turn, game_over, nudge, match_found) carries the game's own language (engine.Variant.Language) on push.Event, and the gateway routes by it (falling back to service_language for non-game pushes). The New-Game variant-gating guarantees the game's bot is one the player has started, so delivery is never blocked. Tests: proactiveNudgeGap unit + retimed TestRobotProactiveNudge; TestVariantLanguage; emit your_turn/game_over language; TestNudgeRoutedByGameLanguage integration. Docs: ARCHITECTURE (§7 nudge, §10/§13 routing), FUNCTIONAL (+ _ru), PLAN tracker.
281 lines
10 KiB
Go
281 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|