diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index d05ddc6..9874d50 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -1,5 +1,7 @@
package adminconsole
+import "html/template"
+
// The *View types are the display models the gin handlers fill and the templates
// render. Time values are pre-formatted to strings by the handlers so the
// templates stay logic-free.
@@ -50,14 +52,19 @@ type UsersView struct {
Pager Pager
}
-// UserRow is one account row in the list.
+// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
+// pre-formatted move-duration summary (empty when it has no timed move).
type UserRow struct {
- ID string
- DisplayName string
- Kind string
- Language string
- Guest bool
- CreatedAt string
+ ID string
+ DisplayName string
+ Kind string
+ Language string
+ Guest bool
+ CreatedAt string
+ HasMoveStats bool
+ MoveMin string
+ MoveAvg string
+ MoveMax string
}
// UserDetailView is one account with its stats, identities and recent games.
@@ -80,6 +87,9 @@ type UserDetailView struct {
Games []GameRow
TelegramID string
ConnectorEnabled bool
+ // MoveChart is the pre-rendered inline SVG of the account's per-move-number think
+ // time (min/mean/max), empty when the account has no timed move.
+ MoveChart template.HTML
}
// StatsRow is an account's lifetime statistics.
diff --git a/backend/internal/game/analytics.go b/backend/internal/game/analytics.go
new file mode 100644
index 0000000..3b0a301
--- /dev/null
+++ b/backend/internal/game/analytics.go
@@ -0,0 +1,116 @@
+package game
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+// A move's "duration" is the think time from the previous move's commit (the moment
+// the turn started) to this move's commit. Only play/pass/exchange moves count;
+// timeouts and resignations are not think time. The very first move of a game has no
+// previous move, so its baseline is the game's creation time. The figures are derived
+// from the move journal (game_moves.created_at), so no schema change is needed.
+//
+// timedMovesCTE is the shared subquery yielding (account, game, ordinal, seconds) for
+// every timed move; the two reports aggregate it differently.
+const timedMovesCTE = `
+ SELECT gp.account_id AS aid,
+ m.game_id AS gid,
+ ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq) AS ord,
+ EXTRACT(EPOCH FROM (m.created_at - COALESCE(prev.created_at, g.created_at))) AS secs
+ FROM backend.game_moves m
+ JOIN backend.games g ON g.game_id = m.game_id
+ LEFT JOIN backend.game_moves prev ON prev.game_id = m.game_id AND prev.seq = m.seq - 1
+ JOIN backend.game_players gp ON gp.game_id = m.game_id AND gp.seat = m.seat
+ WHERE m.action IN ('play', 'pass', 'exchange')`
+
+// MoveDurationStat is the min, max and mean per-move think time (in seconds) for an
+// account across all its games, with the number of timed moves counted.
+type MoveDurationStat struct {
+ MinSecs float64
+ MaxSecs float64
+ AvgSecs float64
+ Moves int
+}
+
+// MoveDurationStats returns the move-duration summary for each of accountIDs that has
+// at least one timed move; accounts with none are absent from the map. It powers the
+// admin user-list columns. The scan over the journal is acceptable for the low-traffic
+// console; per-human analysis is the authoritative use (the live metric aggregates all
+// seats including robots).
+func (s *Store) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
+ if len(accountIDs) == 0 {
+ return map[uuid.UUID]MoveDurationStat{}, nil
+ }
+ q := `WITH d AS (` + timedMovesCTE + `)
+SELECT aid, MIN(secs), MAX(secs), AVG(secs), COUNT(*) FROM d WHERE aid = ANY($1::uuid[]) GROUP BY aid`
+ rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(accountIDs))
+ if err != nil {
+ return nil, fmt.Errorf("game: move-duration stats: %w", err)
+ }
+ defer rows.Close()
+ out := make(map[uuid.UUID]MoveDurationStat, len(accountIDs))
+ for rows.Next() {
+ var id uuid.UUID
+ var st MoveDurationStat
+ if err := rows.Scan(&id, &st.MinSecs, &st.MaxSecs, &st.AvgSecs, &st.Moves); err != nil {
+ return nil, fmt.Errorf("game: scan move-duration stat: %w", err)
+ }
+ out[id] = st
+ }
+ return out, rows.Err()
+}
+
+// OrdinalDuration is the min/max/mean think time (seconds) at an account's k-th move
+// (Ordinal) across all its games.
+type OrdinalDuration struct {
+ Ordinal int
+ MinSecs float64
+ MaxSecs float64
+ AvgSecs float64
+}
+
+// MoveDurationByOrdinal returns the account's per-move-number think-time summary,
+// ordered by move number, for the admin user-detail chart. The ordinal counts the
+// account's own moves within each game (its 1st, 2nd, … move).
+func (s *Store) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
+ q := `WITH d AS (` + timedMovesCTE + ` AND gp.account_id = $1)
+SELECT ord, MIN(secs), MAX(secs), AVG(secs) FROM d GROUP BY ord ORDER BY ord`
+ rows, err := s.db.QueryContext(ctx, q, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("game: move-duration by ordinal: %w", err)
+ }
+ defer rows.Close()
+ var out []OrdinalDuration
+ for rows.Next() {
+ var od OrdinalDuration
+ if err := rows.Scan(&od.Ordinal, &od.MinSecs, &od.MaxSecs, &od.AvgSecs); err != nil {
+ return nil, fmt.Errorf("game: scan ordinal duration: %w", err)
+ }
+ out = append(out, od)
+ }
+ return out, rows.Err()
+}
+
+// uuidArrayLiteral renders ids as a Postgres array literal ("{u1,u2,…}") for an
+// ANY($1::uuid[]) parameter. UUIDs are fixed-format, so the literal is injection-safe.
+func uuidArrayLiteral(ids []uuid.UUID) string {
+ ss := make([]string, len(ids))
+ for i, id := range ids {
+ ss[i] = id.String()
+ }
+ return "{" + strings.Join(ss, ",") + "}"
+}
+
+// MoveDurationStats exposes the store report to the admin console handlers.
+func (svc *Service) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
+ return svc.store.MoveDurationStats(ctx, accountIDs)
+}
+
+// MoveDurationByOrdinal exposes the per-move-number report to the admin console.
+func (svc *Service) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
+ return svc.store.MoveDurationByOrdinal(ctx, accountID)
+}
diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go
new file mode 100644
index 0000000..03f6509
--- /dev/null
+++ b/backend/internal/game/emit_test.go
@@ -0,0 +1,52 @@
+package game
+
+import (
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/engine"
+ "scrabble/backend/internal/notify"
+)
+
+// recordingPublisher captures every published intent for assertions.
+type recordingPublisher struct{ intents []notify.Intent }
+
+func (p *recordingPublisher) Publish(in ...notify.Intent) { p.intents = append(p.intents, in...) }
+
+// TestEmitMoveNotifiesActor checks a committed move sends opponent_moved to every
+// seat — including the actor's own account, so the mover's other devices refresh —
+// and your_turn only to the next mover.
+func TestEmitMoveNotifiesActor(t *testing.T) {
+ actor, opp := uuid.New(), uuid.New()
+ pub := &recordingPublisher{}
+ svc := &Service{pub: pub}
+ g := Game{
+ ID: uuid.New(),
+ Status: StatusActive,
+ ToMove: 1,
+ TurnStartedAt: time.Now(),
+ TurnTimeout: time.Hour,
+ Seats: []Seat{{Seat: 0, AccountID: actor}, {Seat: 1, AccountID: opp}},
+ }
+ svc.emitMove(g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Score: 10, Total: 10})
+
+ kinds := map[uuid.UUID][]string{}
+ for _, in := range pub.intents {
+ kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
+ }
+ if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
+ t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
+ }
+ if !slices.Contains(kinds[opp], notify.KindOpponentMoved) {
+ t.Errorf("opponent should get opponent_moved, got %v", kinds[opp])
+ }
+ if !slices.Contains(kinds[opp], notify.KindYourTurn) {
+ t.Errorf("next mover should get your_turn, got %v", kinds[opp])
+ }
+ if slices.Contains(kinds[actor], notify.KindYourTurn) {
+ t.Errorf("actor is not next to move, should not get your_turn")
+ }
+}
diff --git a/backend/internal/game/metrics.go b/backend/internal/game/metrics.go
index 8a59185..b9596d3 100644
--- a/backend/internal/game/metrics.go
+++ b/backend/internal/game/metrics.go
@@ -22,6 +22,7 @@ const meterName = "scrabble/backend/game"
type gameMetrics struct {
replay metric.Float64Histogram
validate metric.Float64Histogram
+ moveDur metric.Float64Histogram
started metric.Int64Counter
abandoned metric.Int64Counter
}
@@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics {
return &gameMetrics{
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
+ moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."),
started: counter(meter, "games_started_total", "Games created and started."),
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
}
@@ -75,6 +77,30 @@ func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, star
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
}
+// recordMoveDuration records how long a seat spent on a committed move, attributed by
+// variant and the game phase derived from moveCount. A non-positive duration (a clock
+// skew or a move with no recorded turn start) is dropped.
+func (m *gameMetrics) recordMoveDuration(ctx context.Context, v engine.Variant, moveCount int, d time.Duration) {
+ if d <= 0 {
+ return
+ }
+ m.moveDur.Record(ctx, d.Seconds(),
+ metric.WithAttributes(attribute.String("variant", v.String()), attribute.String("phase", phaseOf(moveCount))))
+}
+
+// phaseOf buckets a move ordinal into the game phase used as a metric attribute. The
+// thresholds reflect a typical ~28-move game (docs/ARCHITECTURE.md §7).
+func phaseOf(moveCount int) string {
+ switch {
+ case moveCount <= 8:
+ return "opening"
+ case moveCount <= 20:
+ return "middle"
+ default:
+ return "endgame"
+ }
+}
+
// recordStarted counts one started game of variant.
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
m.started.Add(ctx, 1, variantAttr(v))
diff --git a/backend/internal/game/metrics_test.go b/backend/internal/game/metrics_test.go
index dad8b97..cd1d5c5 100644
--- a/backend/internal/game/metrics_test.go
+++ b/backend/internal/game/metrics_test.go
@@ -26,6 +26,8 @@ func TestGameMetrics(t *testing.T) {
m.recordAbandoned(ctx, engine.VariantErudit)
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
+ m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
+ m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
@@ -45,6 +47,19 @@ func TestGameMetrics(t *testing.T) {
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
}
+ if c := histogramCount(t, rm, "game_move_duration"); c != 1 {
+ t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c)
+ }
+}
+
+// TestPhaseOf checks the move-ordinal to phase bucketing.
+func TestPhaseOf(t *testing.T) {
+ cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"}
+ for mc, want := range cases {
+ if got := phaseOf(mc); got != want {
+ t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want)
+ }
+ }
}
// counterByAttr sums the int64 counter named name, grouped by the value of the
diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go
index 0b220aa..780bf8e 100644
--- a/backend/internal/game/service.go
+++ b/backend/internal/game/service.go
@@ -226,6 +226,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if err != nil {
return MoveResult{}, err
}
+ // Record the seat's think time (turn start to commit) for the move-duration
+ // metric; the timeout path commits separately and is excluded by design.
+ svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
return MoveResult{Move: rec, Game: post}, nil
}
@@ -287,14 +290,15 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
}
// emitMove publishes the live events for a just-committed move: opponent_moved to
-// every seat other than the actor, and your_turn to the next mover while the game
-// is still active. Delivery is best-effort (notify.Publisher never blocks).
+// every seat — including the actor's own account, so the mover's other devices (and
+// their lobby) refresh too — and your_turn to the next mover while the game is still
+// active. opponent_moved is in-app only (the gateway never turns it into an
+// out-of-app push), so the actor is not notified out of band about their own move.
+// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
+// event out to all of the recipient's live streams.
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
intents := make([]notify.Intent, 0, len(post.Seats)+1)
for _, s := range post.Seats {
- if s.Seat == rec.Player {
- continue
- }
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
}
if post.Status == StatusActive {
diff --git a/backend/internal/inttest/analytics_test.go b/backend/internal/inttest/analytics_test.go
new file mode 100644
index 0000000..4697dd8
--- /dev/null
+++ b/backend/internal/inttest/analytics_test.go
@@ -0,0 +1,81 @@
+//go:build integration
+
+package inttest
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/account"
+ "scrabble/backend/internal/game"
+)
+
+// TestMoveDurationAnalytics seeds a game with crafted move timestamps and checks the
+// admin-console move-duration reports compute the think time (gap to the previous
+// move, the first move measured from game creation) correctly, per account and per
+// the account's move ordinal.
+func TestMoveDurationAnalytics(t *testing.T) {
+ ctx := context.Background()
+ accounts := account.NewStore(testDB)
+ a, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
+ if err != nil {
+ t.Fatalf("provision A: %v", err)
+ }
+ b, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
+ if err != nil {
+ t.Fatalf("provision B: %v", err)
+ }
+
+ gid := uuid.New()
+ t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+ if _, err := testDB.ExecContext(ctx,
+ `INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
+ VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil {
+ t.Fatalf("insert game: %v", err)
+ }
+ if _, err := testDB.ExecContext(ctx,
+ `INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil {
+ t.Fatalf("insert seats: %v", err)
+ }
+ // seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200.
+ moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}}
+ for _, m := range moves {
+ if _, err := testDB.ExecContext(ctx,
+ `INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`,
+ gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil {
+ t.Fatalf("insert move %d: %v", m.seq, err)
+ }
+ }
+
+ store := game.NewStore(testDB)
+ stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID})
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 {
+ t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa)
+ }
+ if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 {
+ t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb)
+ }
+
+ byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID)
+ if err != nil {
+ t.Fatalf("by ordinal: %v", err)
+ }
+ want := []game.OrdinalDuration{
+ {Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60},
+ {Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50},
+ }
+ if len(byOrd) != len(want) {
+ t.Fatalf("by ordinal = %+v, want %+v", byOrd, want)
+ }
+ for i, w := range want {
+ if byOrd[i] != w {
+ t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w)
+ }
+ }
+}
diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go
index a212484..33efe14 100644
--- a/backend/internal/inttest/robot_test.go
+++ b/backend/internal/inttest/robot_test.go
@@ -82,13 +82,16 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
if err := r.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool (idempotent): %v", err)
}
- id, err := r.Pick()
+ id, err := r.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
if !isRobotAccount(t, id) {
t.Errorf("picked account %s is not a robot identity", id)
}
+ if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
+ t.Errorf("russian pick = (%s, %v), want a robot account", ru, err)
+ }
acc, err := account.NewStore(testDB).GetByID(ctx, id)
if err != nil {
t.Fatalf("get robot account: %v", err)
@@ -109,7 +112,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
- robotID, err := robots.Pick()
+ robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
@@ -210,7 +213,7 @@ func TestRobotProactiveNudge(t *testing.T) {
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
- robotID, err := robots.Pick()
+ robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
diff --git a/backend/internal/inttest/stage6_test.go b/backend/internal/inttest/stage6_test.go
index bade281..6c3ea48 100644
--- a/backend/internal/inttest/stage6_test.go
+++ b/backend/internal/inttest/stage6_test.go
@@ -38,7 +38,7 @@ func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
- robotID, err := robots.Pick()
+ robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go
index 80395a8..8908a38 100644
--- a/backend/internal/lobby/lobby.go
+++ b/backend/internal/lobby/lobby.go
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
+ "scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
@@ -25,7 +26,7 @@ type GameCreator interface {
// auto-match. robot.Service satisfies it; it returns an error when no robot is
// available so the matchmaker can defer substitution.
type RobotProvider interface {
- Pick() (uuid.UUID, error)
+ Pick(variant engine.Variant) (uuid.UUID, error)
}
// Blocker reports whether two accounts have a block between them (either
diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go
index 649e98f..48da0dd 100644
--- a/backend/internal/lobby/matchmaker.go
+++ b/backend/internal/lobby/matchmaker.go
@@ -197,12 +197,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
}
var subs []sub
for _, acc := range due {
- robotID, err := m.robots.Pick()
+ variant := m.queued[acc]
+ robotID, err := m.robots.Pick(variant)
if err != nil {
m.log.Warn("robot substitution deferred", zap.Error(err))
continue
}
- variant := m.queued[acc]
m.removeLocked(acc, variant)
seats := []uuid.UUID{acc, robotID}
if m.rng.Intn(2) == 0 {
diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go
index 6d59772..e2d145c 100644
--- a/backend/internal/lobby/matchmaker_test.go
+++ b/backend/internal/lobby/matchmaker_test.go
@@ -28,13 +28,15 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
}
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
-// an empty pool.
+// an empty pool. It records the variant of the last substitution request.
type fakeRobots struct {
- id uuid.UUID
- err error
+ id uuid.UUID
+ err error
+ lastVariant engine.Variant
}
-func (f *fakeRobots) Pick() (uuid.UUID, error) {
+func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
+ f.lastVariant = variant
if f.err != nil {
return uuid.Nil, f.err
}
diff --git a/backend/internal/robot/names.go b/backend/internal/robot/names.go
new file mode 100644
index 0000000..a49d2fe
--- /dev/null
+++ b/backend/internal/robot/names.go
@@ -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
+ }
+}
diff --git a/backend/internal/robot/names_test.go b/backend/internal/robot/names_test.go
new file mode 100644
index 0000000..7925b8c
--- /dev/null
+++ b/backend/internal/robot/names_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/robot/robot.go b/backend/internal/robot/robot.go
index f4ef5cc..e6aa40d 100644
--- a/backend/internal/robot/robot.go
+++ b/backend/internal/robot/robot.go
@@ -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
}
diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go
index 7301670..633dd98 100644
--- a/backend/internal/robot/strategy.go
+++ b/backend/internal/robot/strategy.go
@@ -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))
}
diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go
index 3161e4b..e230ea6 100644
--- a/backend/internal/robot/strategy_test.go
+++ b/backend/internal/robot/strategy_test.go
@@ -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)
diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go
index 7f0ae7a..517c8fc 100644
--- a/backend/internal/server/dto_test.go
+++ b/backend/internal/server/dto_test.go
@@ -46,6 +46,7 @@ func TestStatusForError(t *testing.T) {
}{
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
+ "nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go
index dbbe120..f4e64f9 100644
--- a/backend/internal/server/handlers.go
+++ b/backend/internal/server/handlers.go
@@ -148,8 +148,10 @@ func statusForError(err error) (int, string) {
return http.StatusNotFound, "not_found"
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
return http.StatusForbidden, "not_a_player"
- case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn):
+ case errors.Is(err, game.ErrNotYourTurn):
return http.StatusConflict, "not_your_turn"
+ case errors.Is(err, social.ErrNudgeOnOwnTurn):
+ return http.StatusConflict, "nudge_own_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished"
case errors.Is(err, game.ErrGameActive):
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index d8e2265..5fdf3aa 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -82,6 +82,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
return
}
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
+ ids := make([]uuid.UUID, 0, len(accs))
for _, a := range accs {
kind := "registered"
if a.IsGuest {
@@ -91,6 +92,17 @@ func (s *Server) consoleUsers(c *gin.Context) {
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
})
+ ids = append(ids, a.ID)
+ }
+ if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
+ for i := range view.Items {
+ if st, ok := stats[ids[i]]; ok && st.Moves > 0 {
+ view.Items[i].HasMoveStats = true
+ view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs)
+ view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs)
+ view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs)
+ }
+ }
}
s.renderConsole(c, "users", "users", "Users", view)
}
@@ -134,6 +146,13 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Games = append(view.Games, gameRow(g))
}
}
+ if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 {
+ cps := make([]adminconsole.ChartPoint, len(pts))
+ for i, p := range pts {
+ cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs}
+ }
+ view.MoveChart = adminconsole.MoveDurationChart(cps)
+ }
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index 6cf7c09..99b53b5 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -148,6 +148,7 @@ export const en = {
'lang.ru': 'Русский',
'error.not_your_turn': "It is not your turn.",
+ 'error.nudge_own_turn': 'It is your turn — there is no one to nudge.',
'error.illegal_play': 'That is not a legal play.',
'error.hint_unavailable': 'No hints available.',
'error.no_hint_available': 'No options with your letters.',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index 7034bd2..1ffcbf3 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -149,6 +149,7 @@ export const ru: Record = {
'lang.ru': 'Русский',
'error.not_your_turn': 'Сейчас не ваш ход.',
+ 'error.nudge_own_turn': 'Сейчас ваш ход — некого торопить.',
'error.illegal_play': 'Это недопустимый ход.',
'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',