diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index ae6046c..515294d 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -12,7 +12,6 @@ import ( "fmt" "strings" "time" - "unicode/utf8" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" @@ -112,10 +111,41 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string return s.provision(ctx, kind, externalID, provisionSeed{}) } +// ProvisionRobot provisions (or finds) the durable account backing a robot pool +// member: a KindRobot identity carrying displayName, with chat and friend requests +// blocked so the robot never engages socially. Robot names are system-generated, not +// player-edited, so they bypass the editable display-name validation and may carry +// forms the editor rejects (an abbreviated surname like "Peter J."). It is idempotent: +// repeated calls converge the display name and both block flags. +func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) { + acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName}) + if err != nil { + return Account{}, err + } + if acc.DisplayName == displayName && acc.BlockChat && acc.BlockFriendRequests { + return acc, nil + } + stmt := table.Accounts.UPDATE( + table.Accounts.DisplayName, table.Accounts.BlockChat, + table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt, + ).SET( + postgres.String(displayName), postgres.Bool(true), + postgres.Bool(true), postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))). + RETURNING(table.Accounts.AllColumns) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err) + } + return modelToAccount(row), nil +} + // ProvisionTelegram provisions (or finds) the account bound to a Telegram // identity. On first contact only, it seeds the new account's preferred language // from the Telegram client languageCode (when it maps to a supported language) and -// its display name from firstName (falling back to username); an already-existing +// its display name sanitized from firstName (falling back to username, then to a +// generated placeholder when neither yields any letters); an already-existing // account is returned unchanged, so a later profile edit is never overwritten. func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) { return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName)) @@ -155,19 +185,21 @@ type provisionSeed struct { // telegramSeed derives the create-time seed from Telegram launch fields: a // supported preferred language from languageCode (an ISO-639 code, possibly -// region-tagged like "ru-RU"), and a display name from firstName or, failing that, -// username (capped to maxDisplayName runes). +// region-tagged like "ru-RU"), and a display name sanitized from firstName or, +// failing that, username (sanitizeDisplayName strips disallowed characters to the +// editable format). When neither yields any letters, it falls back to a generated +// placeholder in the seeded language (placeholderDisplayName). func telegramSeed(languageCode, username, firstName string) provisionSeed { var seed provisionSeed if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { seed.preferredLanguage = lang } - name := strings.TrimSpace(firstName) + name := sanitizeDisplayName(firstName) if name == "" { - name = strings.TrimSpace(username) + name = sanitizeDisplayName(username) } - if utf8.RuneCountInString(name) > maxDisplayName { - name = string([]rune(name)[:maxDisplayName]) + if name == "" { + name = placeholderDisplayName(seed.preferredLanguage) } seed.displayName = name return seed diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index 03f6150..daf78b5 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "regexp" "strings" "time" + "unicode" "unicode/utf8" "github.com/go-jet/jet/v2/postgres" @@ -110,6 +112,39 @@ func ValidateDisplayName(raw string) (string, error) { return name, nil } +// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram +// first name) to the editable display-name format: it keeps the maximal runs of +// Unicode letters and joins them with a single space, dropping every other rune +// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The +// result therefore always satisfies ValidateDisplayName, or is empty when the input +// carries no letters — in which case the caller substitutes placeholderDisplayName. +// Mirroring the profile editor's rule means a connector-provisioned name is editable +// later without first failing validation. +func sanitizeDisplayName(raw string) string { + fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) }) + if len(fields) == 0 { + return "" + } + name := strings.Join(fields, " ") + if utf8.RuneCountInString(name) > maxDisplayName { + name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ") + } + return name +} + +// placeholderDisplayName builds a fallback display name for a platform account whose +// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or +// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally +// carries digits and a hyphen, so it lies outside the editable format and the player +// is expected to rename it; provisioned names bypass that editor validation. +func placeholderDisplayName(lang string) string { + prefix := "Player" + if lang == "ru" { + prefix = "Игрок" + } + return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000)) +} + // validateAwayWindow checks that the daily away window's duration, wrapping across // midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means // "no away time" and is allowed. diff --git a/backend/internal/account/provision_test.go b/backend/internal/account/provision_test.go index 5417f13..afa08a9 100644 --- a/backend/internal/account/provision_test.go +++ b/backend/internal/account/provision_test.go @@ -1,6 +1,7 @@ package account import ( + "regexp" "strings" "testing" "unicode/utf8" @@ -8,21 +9,25 @@ import ( // TestTelegramSeed covers the pure mapping from Telegram launch fields to the // create-time account seed: supported-language detection (bare and region-tagged), -// the first-name / username display-name precedence, and trimming. +// the first-name / username display-name precedence, and the sanitization that +// strips disallowed characters (emoji, digits, punctuation) to the editable format. func TestTelegramSeed(t *testing.T) { cases := map[string]struct { languageCode, username, firstName string wantLang, wantName string }{ - "ru bare": {"ru", "user", "Иван", "ru", "Иван"}, - "en region-tagged": {"en-US", "user", "John", "en", "John"}, - "ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"}, - "unknown language": {"fr", "frodo", "Frodo", "", "Frodo"}, - "empty language": {"", "neo", "Neo", "", "Neo"}, - "first name wins": {"en", "handle", "Real Name", "en", "Real Name"}, - "username fallback": {"en", "handle", "", "en", "handle"}, - "both empty": {"en", "", "", "en", ""}, - "trimmed": {" RU ", " ", " Anna ", "ru", "Anna"}, + "ru bare": {"ru", "user", "Иван", "ru", "Иван"}, + "en region-tagged": {"en-US", "user", "John", "en", "John"}, + "ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"}, + "unknown language": {"fr", "frodo", "Frodo", "", "Frodo"}, + "empty language": {"", "neo", "Neo", "", "Neo"}, + "first name wins": {"en", "handle", "Real Name", "en", "Real Name"}, + "username fallback": {"en", "handle", "", "en", "handle"}, + "trimmed": {" RU ", " ", " Anna ", "ru", "Anna"}, + "emoji stripped": {"en", "user", "🎮Kaya🎮", "en", "Kaya"}, + "punct to space": {"en", "user", "John❤Doe", "en", "John Doe"}, + "digits dropped": {"ru", "user", "Маша123", "ru", "Маша"}, + "garbage to username": {"en", "good", "123!@#", "en", "good"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -37,6 +42,28 @@ func TestTelegramSeed(t *testing.T) { } } +// TestTelegramSeedPlaceholder checks that a name with no usable letters falls back to +// a generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN"). +func TestTelegramSeedPlaceholder(t *testing.T) { + cases := map[string]struct { + languageCode, username, firstName string + wantRe string + }{ + "en empty": {"en", "", "", `^Player-\d{5}$`}, + "ru empty": {"ru", "", "", `^Игрок-\d{5}$`}, + "default en": {"fr", "", "", `^Player-\d{5}$`}, + "both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := telegramSeed(tc.languageCode, tc.username, tc.firstName).displayName + if !regexp.MustCompile(tc.wantRe).MatchString(got) { + t.Errorf("displayName = %q, want match %s", got, tc.wantRe) + } + }) + } +} + // TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to // maxDisplayName runes (counted in runes, not bytes). func TestTelegramSeedTruncatesLongName(t *testing.T) { diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 780d1cd..8386ee3 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -101,3 +101,17 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } .actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; } .actions form { margin: 0; } .pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; } + +/* Move-timing chart: a server-rendered, script-free inline SVG line chart. */ +.chart { width: 100%; height: auto; max-width: 680px; margin-top: 0.4rem; } +.chart .axis { stroke: var(--line); stroke-width: 1; } +.chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.6; } +.chart .lbl { fill: var(--ink-dim); font-size: 11px; } +.chart .ln { fill: none; stroke-width: 1.5; } +.chart .ln-min { stroke: var(--ok); } +.chart .ln-avg { stroke: var(--accent); } +.chart .ln-max { stroke: var(--danger); } +.lg { font-weight: 600; } +.lg-min { color: var(--ok); } +.lg-avg { color: var(--accent); } +.lg-max { color: var(--danger); } diff --git a/backend/internal/adminconsole/chart.go b/backend/internal/adminconsole/chart.go new file mode 100644 index 0000000..bfa7b39 --- /dev/null +++ b/backend/internal/adminconsole/chart.go @@ -0,0 +1,108 @@ +package adminconsole + +import ( + "fmt" + "html/template" + "strings" + "time" +) + +// ChartPoint is one move-number sample of the move-duration chart: the min, mean and +// max think time (seconds) the account took on its Ordinal-th move across its games. +type ChartPoint struct { + Ordinal int + Min float64 + Max float64 + Avg float64 +} + +// FormatDuration renders a think-time in seconds as a compact human string +// ("45s", "3m", "1h5m"), for the user-list columns and the chart's Y labels. +func FormatDuration(secs float64) string { + d := time.Duration(secs * float64(time.Second)) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds()+0.5)) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes()+0.5)) + default: + h := int(d.Hours()) + if m := int(d.Minutes()) - h*60; m > 0 { + return fmt.Sprintf("%dh%dm", h, m) + } + return fmt.Sprintf("%dh", h) + } +} + +// MoveDurationChart renders the per-move-number think-time chart as a self-contained, +// script-free inline SVG with three series (min, mean, max). The coordinates and +// labels are all derived from numeric data, so the result is safe template.HTML. +// An empty series renders nothing. +func MoveDurationChart(points []ChartPoint) template.HTML { + if len(points) == 0 { + return "" + } + const ( + w, h = 640, 240 + padL = 46 + padR = 12 + padT = 10 + padB = 28 + ) + maxOrd := points[len(points)-1].Ordinal + if maxOrd < 1 { + maxOrd = 1 + } + var maxY float64 + for _, p := range points { + maxY = max(maxY, p.Max) + } + if maxY <= 0 { + maxY = 1 + } + xOf := func(ord int) float64 { + if maxOrd == 1 { + return padL + } + return padL + (float64(ord-1)/float64(maxOrd-1))*(w-padL-padR) + } + yOf := func(v float64) float64 { return padT + (1-v/maxY)*(h-padT-padB) } + line := func(get func(ChartPoint) float64) string { + pts := make([]string, len(points)) + for i, p := range points { + pts[i] = fmt.Sprintf("%.1f,%.1f", xOf(p.Ordinal), yOf(get(p))) + } + return strings.Join(pts, " ") + } + + var b strings.Builder + fmt.Fprintf(&b, ``, w, h) + fmt.Fprintf(&b, ``, padL, padT, padL, float64(h-padB)) + fmt.Fprintf(&b, ``, padL, float64(h-padB), w-padR, float64(h-padB)) + for _, frac := range []float64{0, 0.5, 1} { + v := maxY * frac + y := yOf(v) + fmt.Fprintf(&b, ``, padL, y, w-padR, y) + fmt.Fprintf(&b, `%s`, padL-5, y+3, FormatDuration(v)) + } + for _, ord := range xTicks(maxOrd) { + fmt.Fprintf(&b, `%d`, xOf(ord), h-padB+15, ord) + } + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Max })) + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Avg })) + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Min })) + b.WriteString(``) + return template.HTML(b.String()) +} + +// xTicks returns up to three distinct ordinal labels for the chart's X axis. +func xTicks(maxOrd int) []int { + if maxOrd <= 2 { + out := make([]int, 0, maxOrd) + for i := 1; i <= maxOrd; i++ { + out = append(out, i) + } + return out + } + return []int{1, (maxOrd + 1) / 2, maxOrd} +} diff --git a/backend/internal/adminconsole/chart_test.go b/backend/internal/adminconsole/chart_test.go new file mode 100644 index 0000000..5a53fa9 --- /dev/null +++ b/backend/internal/adminconsole/chart_test.go @@ -0,0 +1,51 @@ +package adminconsole + +import ( + "strings" + "testing" +) + +func TestFormatDuration(t *testing.T) { + cases := map[float64]string{ + 0: "0s", 30: "30s", 59: "59s", 60: "1m", 150: "3m", 3600: "1h", 3660: "1h1m", 7800: "2h10m", + } + for secs, want := range cases { + if got := FormatDuration(secs); got != want { + t.Errorf("FormatDuration(%v) = %q, want %q", secs, got, want) + } + } +} + +func TestMoveDurationChartEmpty(t *testing.T) { + if got := MoveDurationChart(nil); got != "" { + t.Errorf("empty chart = %q, want empty", got) + } +} + +func TestMoveDurationChart(t *testing.T) { + pts := []ChartPoint{{Ordinal: 1, Min: 5, Max: 20, Avg: 10}, {Ordinal: 2, Min: 8, Max: 40, Avg: 18}, {Ordinal: 3, Min: 12, Max: 90, Avg: 30}} + svg := string(MoveDurationChart(pts)) + for _, want := range []string{""} { + if !strings.Contains(svg, want) { + t.Errorf("chart missing %q\n%s", want, svg) + } + } + if n := strings.Count(svg, "no statistics

{{end}} +{{if .MoveChart}} +

Move timing

+

Think time per move number across all games — min · mean · max.

+{{.MoveChart}} +
+{{end}}

Identities

diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index 4ed3b6d..bf2d777 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -2,7 +2,7 @@

Users

{{with .Data}}
KindExternal IDConfirmedCreated
- + {{range .Items}} @@ -11,9 +11,10 @@ +{{if .HasMoveStats}}{{else}}{{end}} {{else}} - + {{end}}
AccountDisplay nameKindLangCreated
AccountDisplay nameKindLangCreatedMove minavgmax
{{.Kind}} {{.Language}} {{.CreatedAt}}{{.MoveMin}}{{.MoveAvg}}{{.MoveMax}}
no users
no users
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': 'Нет вариантов с вашим набором.',