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
+146
View File
@@ -0,0 +1,146 @@
package robot
// Robot display names are composed, not hand-listed. Per language there is a pool of
// 32 full first names and a paired pool of 32 colloquial forms (William/Bill,
// Анастасия/Настя), a surname pool, and three rendering forms: first name only;
// first name plus a surname initial; first name plus full surname. Because robots are
// durable accounts whose name must stay stable across restarts (a player's opponent
// must not rename itself on every deploy, nor mid-game), the composition is
// deterministic per pool slot — seeded by the slot index through mix — rather than
// re-randomised each boot. Russian surnames are gender-agreed with the first name.
// robotPoolSize is the number of robot accounts provisioned per language. It equals
// the first-name pool size, so each slot draws a distinct person.
const robotPoolSize = 32
// latinShareInRussian is the approximate percentage of Russian-variant games that
// draw a Latin-named robot rather than a Russian-named one (the owner's "≤20%").
const latinShareInRussian = 20
// name composition forms.
const (
nameFormFirstOnly = iota // "Anna"
nameFormInitial // "Anna C."
nameFormFull // "Anna Carter"
)
// genderedName is a Russian first name tagged by grammatical gender so the surname
// form (masculine vs feminine) can agree with it.
type genderedName struct {
name string
female bool
}
// surnamePair holds a Russian surname's masculine and feminine forms.
type surnamePair struct{ m, f string }
// firstNamesFullEN and firstNamesShortEN are paired by index: the same person's
// official and colloquial English first name (William/Bill).
var firstNamesFullEN = []string{
"William", "Robert", "Thomas", "Nicholas", "Joseph", "Edward", "Charles", "Margaret",
"Elizabeth", "Katherine", "Alexander", "Samuel", "Andrew", "Christopher", "Benjamin", "Daniel",
"Matthew", "Anthony", "Michael", "Richard", "Jonathan", "Patricia", "Jennifer", "Jessica",
"Stephanie", "Victoria", "Theodore", "Frederick", "Gabriel", "Vincent", "Eleanor", "Josephine",
}
var firstNamesShortEN = []string{
"Will", "Bob", "Tom", "Nick", "Joe", "Eddie", "Charlie", "Maggie",
"Liz", "Kate", "Alex", "Sam", "Drew", "Chris", "Ben", "Dan",
"Matt", "Tony", "Mike", "Rick", "Jon", "Pat", "Jen", "Jess",
"Steph", "Vicky", "Ted", "Fred", "Gabe", "Vince", "Ellie", "Josie",
}
// surnamesEN is a pool of gender-neutral English surnames.
var surnamesEN = []string{
"Carter", "Hayes", "Brooks", "Reed", "Cole", "Lane", "Bishop", "Hart",
"Shaw", "Wells", "Pierce", "Wade", "Frost", "Doyle", "Boyd", "Marsh",
"Hale", "Nash", "Webb", "Dale", "Park", "Lyon", "Snow", "Cross",
"Vance", "Knox", "Page", "Ford", "Banks", "Foster", "Greer", "Mills",
}
// firstNamesFullRU and firstNamesShortRU are paired by index: the same person's
// official and colloquial Russian first name (Анастасия/Настя), gender-tagged.
var firstNamesFullRU = []genderedName{
{"Александр", false}, {"Дмитрий", false}, {"Сергей", false}, {"Алексей", false},
{"Михаил", false}, {"Николай", false}, {"Владимир", false}, {"Константин", false},
{"Павел", false}, {"Григорий", false}, {"Андрей", false}, {"Иван", false},
{"Пётр", false}, {"Роман", false}, {"Антон", false}, {"Евгений", false},
{"Анна", true}, {"Мария", true}, {"Анастасия", true}, {"Наталья", true},
{"Татьяна", true}, {"Екатерина", true}, {"Юлия", true}, {"Ольга", true},
{"Елена", true}, {"Ирина", true}, {"Светлана", true}, {"Полина", true},
{"Дарья", true}, {"София", true}, {"Алина", true}, {"Вера", true},
}
var firstNamesShortRU = []genderedName{
{"Саша", false}, {"Дима", false}, {"Серёжа", false}, {"Лёша", false},
{"Миша", false}, {"Коля", false}, {"Володя", false}, {"Костя", false},
{"Паша", false}, {"Гриша", false}, {"Андрюша", false}, {"Ваня", false},
{"Петя", false}, {"Рома", false}, {"Антоша", false}, {"Женя", false},
{"Аня", true}, {"Маша", true}, {"Настя", true}, {"Наташа", true},
{"Таня", true}, {"Катя", true}, {"Юля", true}, {"Оля", true},
{"Лена", true}, {"Ира", true}, {"Света", true}, {"Поля", true},
{"Даша", true}, {"Соня", true}, {"Аля", true}, {"Верочка", true},
}
// surnamesRU is a pool of common Russian surnames in masculine and feminine forms.
var surnamesRU = []surnamePair{
{"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
{"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
{"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
{"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
{"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
{"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
{"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
{"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
{"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
{"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
{"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"},
}
// robotDisplayNamesEN builds the English robot display-name pool, one per slot. Each
// slot draws its paired full or colloquial first name, a surname, and a form.
func robotDisplayNamesEN() []string {
out := make([]string, robotPoolSize)
for i := range out {
h := mix(int64(i), "robot-en")
first := firstNamesFullEN[i%len(firstNamesFullEN)]
if (h>>16)&1 == 1 {
first = firstNamesShortEN[i%len(firstNamesShortEN)]
}
surname := surnamesEN[h%uint64(len(surnamesEN))]
out[i] = composeName(first, surname, int((h>>8)%3))
}
return out
}
// robotDisplayNamesRU builds the Russian robot display-name pool, one per slot, with
// the surname form agreeing with the first name's gender.
func robotDisplayNamesRU() []string {
out := make([]string, robotPoolSize)
for i := range out {
h := mix(int64(i), "robot-ru")
fn := firstNamesFullRU[i%len(firstNamesFullRU)]
if (h>>16)&1 == 1 {
fn = firstNamesShortRU[i%len(firstNamesShortRU)]
}
sp := surnamesRU[h%uint64(len(surnamesRU))]
surname := sp.m
if fn.female {
surname = sp.f
}
out[i] = composeName(fn.name, surname, int((h>>8)%3))
}
return out
}
// composeName renders one of the three name forms from a first name and a surname.
func composeName(first, surname string, form int) string {
switch form {
case nameFormInitial:
return first + " " + string([]rune(surname)[:1]) + "."
case nameFormFull:
return first + " " + surname
default:
return first
}
}
+119
View File
@@ -0,0 +1,119 @@
package robot
import (
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// TestComposeName covers the three rendering forms, including a Cyrillic initial.
func TestComposeName(t *testing.T) {
cases := []struct {
first, surname string
form int
want string
}{
{"Anna", "Carter", nameFormFirstOnly, "Anna"},
{"Anna", "Carter", nameFormInitial, "Anna C."},
{"Anna", "Carter", nameFormFull, "Anna Carter"},
{"Маша", "Суханова", nameFormInitial, "Маша С."},
{"Маша", "Суханова", nameFormFull, "Маша Суханова"},
}
for _, c := range cases {
if got := composeName(c.first, c.surname, c.form); got != c.want {
t.Errorf("composeName(%q,%q,%d) = %q, want %q", c.first, c.surname, c.form, got, c.want)
}
}
}
// TestNamePoolsPaired checks the full and colloquial first-name pools line up by
// index (so a slot's gender and person are consistent) and the surname forms differ.
func TestNamePoolsPaired(t *testing.T) {
if len(firstNamesFullEN) != robotPoolSize || len(firstNamesShortEN) != robotPoolSize {
t.Fatalf("EN first-name pools must each hold %d names", robotPoolSize)
}
if len(firstNamesFullRU) != robotPoolSize || len(firstNamesShortRU) != robotPoolSize {
t.Fatalf("RU first-name pools must each hold %d names", robotPoolSize)
}
for i := range firstNamesFullRU {
if firstNamesFullRU[i].female != firstNamesShortRU[i].female {
t.Errorf("RU pair %d disagrees on gender: %q vs %q", i, firstNamesFullRU[i].name, firstNamesShortRU[i].name)
}
}
for _, sp := range surnamesRU {
if sp.m == sp.f {
t.Errorf("RU surname forms should differ: %q", sp.m)
}
}
}
// TestRobotDisplayNames checks the generated pools are the right size, non-empty and
// deterministic — durable robot accounts must keep a stable name across restarts.
func TestRobotDisplayNames(t *testing.T) {
en1, en2 := robotDisplayNamesEN(), robotDisplayNamesEN()
ru1, ru2 := robotDisplayNamesRU(), robotDisplayNamesRU()
if len(en1) != robotPoolSize || len(ru1) != robotPoolSize {
t.Fatalf("pool sizes en=%d ru=%d, want %d", len(en1), len(ru1), robotPoolSize)
}
for i := range en1 {
if en1[i] != en2[i] || ru1[i] != ru2[i] {
t.Fatalf("pool not deterministic at %d: en %q/%q ru %q/%q", i, en1[i], en2[i], ru1[i], ru2[i])
}
if en1[i] == "" || ru1[i] == "" {
t.Fatalf("empty composed name at index %d", i)
}
}
}
// TestPickVariantRouting checks English games draw the Latin pool and Russian games
// draw mostly Russian names with a Latin minority.
func TestPickVariantRouting(t *testing.T) {
enID, ruID := uuid.New(), uuid.New()
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
for i := 0; i < 200; i++ {
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID)
}
}
var en, ru int
for i := 0; i < 4000; i++ {
got, err := s.Pick(engine.VariantRussianScrabble)
if err != nil {
t.Fatalf("russian Pick: %v", err)
}
switch got {
case enID:
en++
case ruID:
ru++
}
}
if ru <= en {
t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en)
}
if en == 0 {
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
}
// Эрудит routes like Russian Scrabble.
if _, err := s.Pick(engine.VariantErudit); err != nil {
t.Errorf("erudit Pick: %v", err)
}
}
// TestPickFallback checks an empty side falls back to the other pool and an empty pool
// errors.
func TestPickFallback(t *testing.T) {
id := uuid.New()
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
}
if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
}
if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
}
}
+59 -50
View File
@@ -55,13 +55,6 @@ type Nudger interface {
LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error)
}
// robotNames is the curated, human-like name pool. Each name backs one durable
// robot account, addressed by a stable robot identity (its lower-cased name).
var robotNames = []string{
"Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan",
"Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage",
}
// Config configures the robot subsystem.
type Config struct {
// DriveInterval is how often the driver scans for robot turns. Sourced from
@@ -91,8 +84,9 @@ type Service struct {
clock func() time.Time
log *zap.Logger
mu sync.RWMutex
pool []uuid.UUID
mu sync.RWMutex
poolEN []uuid.UUID
poolRU []uuid.UUID
}
// NewService constructs a robot Service. games and social are the domain seams it
@@ -120,58 +114,73 @@ func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter met
}
}
// EnsurePool idempotently provisions the named robot accounts and records their
// ids as the pool. Each robot is a durable account bound to a robot identity,
// with chat and friend requests blocked so it never engages socially
// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary
// registry: a failure fails the boot.
// EnsurePool idempotently provisions the robot accounts (one per slot of each
// language's composed name pool) and records their ids. Each robot is a durable
// account bound to a stable, index-keyed robot identity, with chat and friend
// requests blocked so it never engages socially (docs/ARCHITECTURE.md §7). It is a
// startup dependency, like the dictionary registry: a failure fails the boot.
func (s *Service) EnsurePool(ctx context.Context) error {
ids := make([]uuid.UUID, 0, len(robotNames))
for _, name := range robotNames {
acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name))
if err != nil {
return fmt.Errorf("robot: provision %q: %w", name, err)
}
if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests {
if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
DisplayName: name,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart,
AwayEnd: acc.AwayEnd,
BlockChat: true,
BlockFriendRequests: true,
}); err != nil {
return fmt.Errorf("robot: profile %q: %w", name, err)
}
}
ids = append(ids, acc.ID)
en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN())
if err != nil {
return err
}
ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU())
if err != nil {
return err
}
s.mu.Lock()
s.pool = ids
s.poolEN, s.poolRU = en, ru
s.mu.Unlock()
return nil
}
// Pick returns a random robot account from the pool, for the matchmaker to
// substitute into an auto-match. It satisfies lobby.RobotProvider.
func (s *Service) Pick() (uuid.UUID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.pool) == 0 {
return uuid.Nil, ErrNoRobotAvailable
// provisionPool provisions one durable robot account per name and returns their ids
// in order. The identity is keyed by language and slot index (stable across restarts
// and independent of the composed display name); account.ProvisionRobot sets the
// display name and social blocks and is idempotent, so EnsurePool can run every boot.
func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) {
ids := make([]uuid.UUID, 0, len(names))
for i, name := range names {
acc, err := s.accounts.ProvisionRobot(ctx, fmt.Sprintf("robot-%s-%d", lang, i), name)
if err != nil {
return nil, fmt.Errorf("robot: provision %s #%d (%q): %w", lang, i, name, err)
}
ids = append(ids, acc.ID)
}
return s.pool[rand.IntN(len(s.pool))], nil
return ids, nil
}
// poolIDs returns a snapshot of the pool for the driver scan.
// Pick returns a random robot account for the matchmaker to substitute into an
// auto-match of the given variant. An English game draws from the Latin pool; a
// Russian game (Russian Scrabble or Эрудит) draws from the Russian pool, mixing in a
// Latin name about latinShareInRussian% of the time; either side falls back to the
// other when its pool is empty. It satisfies lobby.RobotProvider.
func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
primary, secondary := s.poolEN, s.poolRU
if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit {
primary, secondary = s.poolRU, s.poolEN
if len(primary) > 0 && len(secondary) > 0 && rand.IntN(100) < latinShareInRussian {
primary, secondary = secondary, primary
}
}
if len(primary) == 0 {
primary = secondary
}
if len(primary) == 0 {
return uuid.Nil, ErrNoRobotAvailable
}
return primary[rand.IntN(len(primary))], nil
}
// poolIDs returns a snapshot of the whole pool (both languages) for the driver scan,
// which is variant-agnostic — it acts on every robot's active games.
func (s *Service) poolIDs() []uuid.UUID {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]uuid.UUID(nil), s.pool...)
}
// externalID is the stable robot identity for a pool name.
func externalID(name string) string {
return "robot-" + name
ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU))
ids = append(ids, s.poolEN...)
ids = append(ids, s.poolRU...)
return ids
}
+55 -16
View File
@@ -23,17 +23,27 @@ const (
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
playToWinPercent = 40
// delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the
// right-skewed distribution (short delays frequent). With skew 3.5 the median
// is about 10 minutes and the mean about 20, with a tail out to the maximum.
delayMinMinutes = 2.0
delayMaxMinutes = 90.0
delaySkew = 3.5
// The robot's think time depends on how far the game has progressed: early moves
// are quick and late moves can be long (endgame deliberation). The delay is drawn
// from a band that interpolates with the move count from [delayEarlyLoMinutes,
// delayEarlyHiMinutes] at the first move to [delayLateLoMinutes, delayLateHiMinutes]
// by avgGameMoves, then right-skewed by delaySkew (a larger exponent concentrates
// delays near the band's floor — an active player). The result is clamped to
// [delayHardMinMinutes, delayHardMaxMinutes]. The numbers are deliberate estimates,
// to be retuned once real play statistics arrive (docs/ARCHITECTURE.md §7).
delayEarlyLoMinutes = 1.0
delayEarlyHiMinutes = 5.0
delayLateLoMinutes = 10.0
delayLateHiMinutes = 90.0
delaySkew = 4.0
avgGameMoves = 28.0
delayHardMinMinutes = 1.0
delayHardMaxMinutes = 90.0
// nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot
// answers a daytime nudge on its turn.
nudgeReplyMinMinutes = 2.0
nudgeReplyMaxMinutes = 10.0
// nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's
// lower band (delayBand's lo), within which the robot answers a daytime nudge on
// its turn — so a nudged robot replies near the floor of its think time.
nudgeReplySpreadMinutes = 5.0
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
// (opponent-anchored, drifted) local time: it makes no move and sends no nudge
@@ -104,19 +114,48 @@ func playToWin(seed int64) bool {
return mix(seed, "win")%100 < playToWinPercent
}
// moveDelay is the robot's think time for the move at moveCount, sampled from the
// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes).
// delayBand returns the lower and upper bounds, in minutes, of the move-delay band
// for the move at moveCount. It interpolates linearly with game progress (the move
// count over avgGameMoves, capped at 1): early moves sit in a short band and late
// moves in a long one.
func delayBand(moveCount int) (lo, hi float64) {
p := float64(moveCount) / avgGameMoves
if p > 1 {
p = 1
}
lo = delayEarlyLoMinutes + (delayLateLoMinutes-delayEarlyLoMinutes)*p
hi = delayEarlyHiMinutes + (delayLateHiMinutes-delayEarlyHiMinutes)*p
return lo, hi
}
// moveDelay is the robot's think time for the move at moveCount: a right-skewed
// sample from the move's delayBand, clamped to the hard bounds. The skew (delaySkew
// > 1) makes short delays frequent and long ones rare, with a tail to the band's top.
func moveDelay(seed int64, moveCount int) time.Duration {
lo, hi := delayBand(moveCount)
u := unitFloat(mix(seed, "delay", moveCount))
mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew)
return time.Duration(mins * float64(time.Minute))
return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew))
}
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes).
// moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes],
// where lo is the move's lower band — so a nudge pulls the move in near the floor of
// the robot's think time.
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
lo, _ := delayBand(moveCount)
u := unitFloat(mix(seed, "nudge", moveCount))
mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
}
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
// bounds so an out-of-range band can never produce an absurd think time.
func clampMinutes(mins float64) time.Duration {
if mins < delayHardMinMinutes {
mins = delayHardMinMinutes
}
if mins > delayHardMaxMinutes {
mins = delayHardMaxMinutes
}
return time.Duration(mins * float64(time.Minute))
}
+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)