Stage 17: backend defect fixes (nudge code, TG name, robot names/timing, multi-device push, move-duration metric + admin analytics)

- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn')
- #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback
- #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin)
- #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4)
- #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only
- #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal)
- ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
This commit is contained in:
Ilia Denisov
2026-06-06 09:59:12 +02:00
parent 6886efb6c0
commit 635f2fd9fc
30 changed files with 1068 additions and 120 deletions
+40 -8
View File
@@ -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
+35
View File
@@ -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.
+37 -10
View File
@@ -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) {
@@ -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); }
+108
View File
@@ -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, `<svg viewBox="0 0 %d %d" class="chart" role="img" aria-label="Move duration by move number">`, w, h)
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%.1f" class="axis"/>`, padL, padT, padL, float64(h-padB))
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="axis"/>`, 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, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="grid"/>`, padL, y, w-padR, y)
fmt.Fprintf(&b, `<text x="%d" y="%.1f" class="lbl" text-anchor="end">%s</text>`, padL-5, y+3, FormatDuration(v))
}
for _, ord := range xTicks(maxOrd) {
fmt.Fprintf(&b, `<text x="%.1f" y="%d" class="lbl" text-anchor="middle">%d</text>`, xOf(ord), h-padB+15, ord)
}
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-max"/>`, line(func(p ChartPoint) float64 { return p.Max }))
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-avg"/>`, line(func(p ChartPoint) float64 { return p.Avg }))
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-min"/>`, line(func(p ChartPoint) float64 { return p.Min }))
b.WriteString(`</svg>`)
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}
}
@@ -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{"<svg", "ln-min", "ln-avg", "ln-max", "</svg>"} {
if !strings.Contains(svg, want) {
t.Errorf("chart missing %q\n%s", want, svg)
}
}
if n := strings.Count(svg, "<polyline"); n != 3 {
t.Errorf("polylines = %d, want 3", n)
}
}
func TestXTicks(t *testing.T) {
cases := map[int][]int{1: {1}, 2: {1, 2}, 3: {1, 2, 3}, 10: {1, 5, 10}}
for maxOrd, want := range cases {
got := xTicks(maxOrd)
if len(got) != len(want) {
t.Fatalf("xTicks(%d) = %v, want %v", maxOrd, got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("xTicks(%d) = %v, want %v", maxOrd, got, want)
}
}
}
}
@@ -28,6 +28,12 @@
{{else}}<p class="note">no statistics</p>{{end}}
</section>
</div>
{{if .MoveChart}}
<section class="panel"><h2>Move timing</h2>
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
{{.MoveChart}}
</section>
{{end}}
<section class="panel"><h2>Identities</h2>
<table class="list">
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
@@ -2,7 +2,7 @@
<h1>Users</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
@@ -11,9 +11,10 @@
<td>{{.Kind}}</td>
<td>{{.Language}}</td>
<td>{{.CreatedAt}}</td>
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
</tr>
{{else}}
<tr><td colspan="5"><span class="note">no users</span></td></tr>
<tr><td colspan="8"><span class="note">no users</span></td></tr>
{{end}}
</tbody>
</table>
+17 -7
View File
@@ -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.
+116
View File
@@ -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)
}
+52
View File
@@ -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")
}
}
+26
View File
@@ -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))
+15
View File
@@ -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
+9 -5
View File
@@ -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 {
@@ -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)
}
}
}
+6 -3
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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 {
+6 -4
View File
@@ -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
}
+146
View File
@@ -0,0 +1,146 @@
package robot
// Robot display names are composed, not hand-listed. Per language there is a pool of
// 32 full first names and a paired pool of 32 colloquial forms (William/Bill,
// Анастасия/Настя), a surname pool, and three rendering forms: first name only;
// first name plus a surname initial; first name plus full surname. Because robots are
// durable accounts whose name must stay stable across restarts (a player's opponent
// must not rename itself on every deploy, nor mid-game), the composition is
// deterministic per pool slot — seeded by the slot index through mix — rather than
// re-randomised each boot. Russian surnames are gender-agreed with the first name.
// robotPoolSize is the number of robot accounts provisioned per language. It equals
// the first-name pool size, so each slot draws a distinct person.
const robotPoolSize = 32
// latinShareInRussian is the approximate percentage of Russian-variant games that
// draw a Latin-named robot rather than a Russian-named one (the owner's "≤20%").
const latinShareInRussian = 20
// name composition forms.
const (
nameFormFirstOnly = iota // "Anna"
nameFormInitial // "Anna C."
nameFormFull // "Anna Carter"
)
// genderedName is a Russian first name tagged by grammatical gender so the surname
// form (masculine vs feminine) can agree with it.
type genderedName struct {
name string
female bool
}
// surnamePair holds a Russian surname's masculine and feminine forms.
type surnamePair struct{ m, f string }
// firstNamesFullEN and firstNamesShortEN are paired by index: the same person's
// official and colloquial English first name (William/Bill).
var firstNamesFullEN = []string{
"William", "Robert", "Thomas", "Nicholas", "Joseph", "Edward", "Charles", "Margaret",
"Elizabeth", "Katherine", "Alexander", "Samuel", "Andrew", "Christopher", "Benjamin", "Daniel",
"Matthew", "Anthony", "Michael", "Richard", "Jonathan", "Patricia", "Jennifer", "Jessica",
"Stephanie", "Victoria", "Theodore", "Frederick", "Gabriel", "Vincent", "Eleanor", "Josephine",
}
var firstNamesShortEN = []string{
"Will", "Bob", "Tom", "Nick", "Joe", "Eddie", "Charlie", "Maggie",
"Liz", "Kate", "Alex", "Sam", "Drew", "Chris", "Ben", "Dan",
"Matt", "Tony", "Mike", "Rick", "Jon", "Pat", "Jen", "Jess",
"Steph", "Vicky", "Ted", "Fred", "Gabe", "Vince", "Ellie", "Josie",
}
// surnamesEN is a pool of gender-neutral English surnames.
var surnamesEN = []string{
"Carter", "Hayes", "Brooks", "Reed", "Cole", "Lane", "Bishop", "Hart",
"Shaw", "Wells", "Pierce", "Wade", "Frost", "Doyle", "Boyd", "Marsh",
"Hale", "Nash", "Webb", "Dale", "Park", "Lyon", "Snow", "Cross",
"Vance", "Knox", "Page", "Ford", "Banks", "Foster", "Greer", "Mills",
}
// firstNamesFullRU and firstNamesShortRU are paired by index: the same person's
// official and colloquial Russian first name (Анастасия/Настя), gender-tagged.
var firstNamesFullRU = []genderedName{
{"Александр", false}, {"Дмитрий", false}, {"Сергей", false}, {"Алексей", false},
{"Михаил", false}, {"Николай", false}, {"Владимир", false}, {"Константин", false},
{"Павел", false}, {"Григорий", false}, {"Андрей", false}, {"Иван", false},
{"Пётр", false}, {"Роман", false}, {"Антон", false}, {"Евгений", false},
{"Анна", true}, {"Мария", true}, {"Анастасия", true}, {"Наталья", true},
{"Татьяна", true}, {"Екатерина", true}, {"Юлия", true}, {"Ольга", true},
{"Елена", true}, {"Ирина", true}, {"Светлана", true}, {"Полина", true},
{"Дарья", true}, {"София", true}, {"Алина", true}, {"Вера", true},
}
var firstNamesShortRU = []genderedName{
{"Саша", false}, {"Дима", false}, {"Серёжа", false}, {"Лёша", false},
{"Миша", false}, {"Коля", false}, {"Володя", false}, {"Костя", false},
{"Паша", false}, {"Гриша", false}, {"Андрюша", false}, {"Ваня", false},
{"Петя", false}, {"Рома", false}, {"Антоша", false}, {"Женя", false},
{"Аня", true}, {"Маша", true}, {"Настя", true}, {"Наташа", true},
{"Таня", true}, {"Катя", true}, {"Юля", true}, {"Оля", true},
{"Лена", true}, {"Ира", true}, {"Света", true}, {"Поля", true},
{"Даша", true}, {"Соня", true}, {"Аля", true}, {"Верочка", true},
}
// surnamesRU is a pool of common Russian surnames in masculine and feminine forms.
var surnamesRU = []surnamePair{
{"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
{"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
{"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
{"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
{"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
{"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
{"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
{"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
{"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
{"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
{"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"},
}
// robotDisplayNamesEN builds the English robot display-name pool, one per slot. Each
// slot draws its paired full or colloquial first name, a surname, and a form.
func robotDisplayNamesEN() []string {
out := make([]string, robotPoolSize)
for i := range out {
h := mix(int64(i), "robot-en")
first := firstNamesFullEN[i%len(firstNamesFullEN)]
if (h>>16)&1 == 1 {
first = firstNamesShortEN[i%len(firstNamesShortEN)]
}
surname := surnamesEN[h%uint64(len(surnamesEN))]
out[i] = composeName(first, surname, int((h>>8)%3))
}
return out
}
// robotDisplayNamesRU builds the Russian robot display-name pool, one per slot, with
// the surname form agreeing with the first name's gender.
func robotDisplayNamesRU() []string {
out := make([]string, robotPoolSize)
for i := range out {
h := mix(int64(i), "robot-ru")
fn := firstNamesFullRU[i%len(firstNamesFullRU)]
if (h>>16)&1 == 1 {
fn = firstNamesShortRU[i%len(firstNamesShortRU)]
}
sp := surnamesRU[h%uint64(len(surnamesRU))]
surname := sp.m
if fn.female {
surname = sp.f
}
out[i] = composeName(fn.name, surname, int((h>>8)%3))
}
return out
}
// composeName renders one of the three name forms from a first name and a surname.
func composeName(first, surname string, form int) string {
switch form {
case nameFormInitial:
return first + " " + string([]rune(surname)[:1]) + "."
case nameFormFull:
return first + " " + surname
default:
return first
}
}
+119
View File
@@ -0,0 +1,119 @@
package robot
import (
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// TestComposeName covers the three rendering forms, including a Cyrillic initial.
func TestComposeName(t *testing.T) {
cases := []struct {
first, surname string
form int
want string
}{
{"Anna", "Carter", nameFormFirstOnly, "Anna"},
{"Anna", "Carter", nameFormInitial, "Anna C."},
{"Anna", "Carter", nameFormFull, "Anna Carter"},
{"Маша", "Суханова", nameFormInitial, "Маша С."},
{"Маша", "Суханова", nameFormFull, "Маша Суханова"},
}
for _, c := range cases {
if got := composeName(c.first, c.surname, c.form); got != c.want {
t.Errorf("composeName(%q,%q,%d) = %q, want %q", c.first, c.surname, c.form, got, c.want)
}
}
}
// TestNamePoolsPaired checks the full and colloquial first-name pools line up by
// index (so a slot's gender and person are consistent) and the surname forms differ.
func TestNamePoolsPaired(t *testing.T) {
if len(firstNamesFullEN) != robotPoolSize || len(firstNamesShortEN) != robotPoolSize {
t.Fatalf("EN first-name pools must each hold %d names", robotPoolSize)
}
if len(firstNamesFullRU) != robotPoolSize || len(firstNamesShortRU) != robotPoolSize {
t.Fatalf("RU first-name pools must each hold %d names", robotPoolSize)
}
for i := range firstNamesFullRU {
if firstNamesFullRU[i].female != firstNamesShortRU[i].female {
t.Errorf("RU pair %d disagrees on gender: %q vs %q", i, firstNamesFullRU[i].name, firstNamesShortRU[i].name)
}
}
for _, sp := range surnamesRU {
if sp.m == sp.f {
t.Errorf("RU surname forms should differ: %q", sp.m)
}
}
}
// TestRobotDisplayNames checks the generated pools are the right size, non-empty and
// deterministic — durable robot accounts must keep a stable name across restarts.
func TestRobotDisplayNames(t *testing.T) {
en1, en2 := robotDisplayNamesEN(), robotDisplayNamesEN()
ru1, ru2 := robotDisplayNamesRU(), robotDisplayNamesRU()
if len(en1) != robotPoolSize || len(ru1) != robotPoolSize {
t.Fatalf("pool sizes en=%d ru=%d, want %d", len(en1), len(ru1), robotPoolSize)
}
for i := range en1 {
if en1[i] != en2[i] || ru1[i] != ru2[i] {
t.Fatalf("pool not deterministic at %d: en %q/%q ru %q/%q", i, en1[i], en2[i], ru1[i], ru2[i])
}
if en1[i] == "" || ru1[i] == "" {
t.Fatalf("empty composed name at index %d", i)
}
}
}
// TestPickVariantRouting checks English games draw the Latin pool and Russian games
// draw mostly Russian names with a Latin minority.
func TestPickVariantRouting(t *testing.T) {
enID, ruID := uuid.New(), uuid.New()
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
for i := 0; i < 200; i++ {
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID)
}
}
var en, ru int
for i := 0; i < 4000; i++ {
got, err := s.Pick(engine.VariantRussianScrabble)
if err != nil {
t.Fatalf("russian Pick: %v", err)
}
switch got {
case enID:
en++
case ruID:
ru++
}
}
if ru <= en {
t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en)
}
if en == 0 {
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
}
// Эрудит routes like Russian Scrabble.
if _, err := s.Pick(engine.VariantErudit); err != nil {
t.Errorf("erudit Pick: %v", err)
}
}
// TestPickFallback checks an empty side falls back to the other pool and an empty pool
// errors.
func TestPickFallback(t *testing.T) {
id := uuid.New()
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
}
if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
}
if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
}
}
+59 -50
View File
@@ -55,13 +55,6 @@ type Nudger interface {
LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error)
}
// robotNames is the curated, human-like name pool. Each name backs one durable
// robot account, addressed by a stable robot identity (its lower-cased name).
var robotNames = []string{
"Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan",
"Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage",
}
// Config configures the robot subsystem.
type Config struct {
// DriveInterval is how often the driver scans for robot turns. Sourced from
@@ -91,8 +84,9 @@ type Service struct {
clock func() time.Time
log *zap.Logger
mu sync.RWMutex
pool []uuid.UUID
mu sync.RWMutex
poolEN []uuid.UUID
poolRU []uuid.UUID
}
// NewService constructs a robot Service. games and social are the domain seams it
@@ -120,58 +114,73 @@ func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter met
}
}
// EnsurePool idempotently provisions the named robot accounts and records their
// ids as the pool. Each robot is a durable account bound to a robot identity,
// with chat and friend requests blocked so it never engages socially
// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary
// registry: a failure fails the boot.
// EnsurePool idempotently provisions the robot accounts (one per slot of each
// language's composed name pool) and records their ids. Each robot is a durable
// account bound to a stable, index-keyed robot identity, with chat and friend
// requests blocked so it never engages socially (docs/ARCHITECTURE.md §7). It is a
// startup dependency, like the dictionary registry: a failure fails the boot.
func (s *Service) EnsurePool(ctx context.Context) error {
ids := make([]uuid.UUID, 0, len(robotNames))
for _, name := range robotNames {
acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name))
if err != nil {
return fmt.Errorf("robot: provision %q: %w", name, err)
}
if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests {
if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
DisplayName: name,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart,
AwayEnd: acc.AwayEnd,
BlockChat: true,
BlockFriendRequests: true,
}); err != nil {
return fmt.Errorf("robot: profile %q: %w", name, err)
}
}
ids = append(ids, acc.ID)
en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN())
if err != nil {
return err
}
ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU())
if err != nil {
return err
}
s.mu.Lock()
s.pool = ids
s.poolEN, s.poolRU = en, ru
s.mu.Unlock()
return nil
}
// Pick returns a random robot account from the pool, for the matchmaker to
// substitute into an auto-match. It satisfies lobby.RobotProvider.
func (s *Service) Pick() (uuid.UUID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.pool) == 0 {
return uuid.Nil, ErrNoRobotAvailable
// provisionPool provisions one durable robot account per name and returns their ids
// in order. The identity is keyed by language and slot index (stable across restarts
// and independent of the composed display name); account.ProvisionRobot sets the
// display name and social blocks and is idempotent, so EnsurePool can run every boot.
func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) {
ids := make([]uuid.UUID, 0, len(names))
for i, name := range names {
acc, err := s.accounts.ProvisionRobot(ctx, fmt.Sprintf("robot-%s-%d", lang, i), name)
if err != nil {
return nil, fmt.Errorf("robot: provision %s #%d (%q): %w", lang, i, name, err)
}
ids = append(ids, acc.ID)
}
return s.pool[rand.IntN(len(s.pool))], nil
return ids, nil
}
// poolIDs returns a snapshot of the pool for the driver scan.
// Pick returns a random robot account for the matchmaker to substitute into an
// auto-match of the given variant. An English game draws from the Latin pool; a
// Russian game (Russian Scrabble or Эрудит) draws from the Russian pool, mixing in a
// Latin name about latinShareInRussian% of the time; either side falls back to the
// other when its pool is empty. It satisfies lobby.RobotProvider.
func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
primary, secondary := s.poolEN, s.poolRU
if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit {
primary, secondary = s.poolRU, s.poolEN
if len(primary) > 0 && len(secondary) > 0 && rand.IntN(100) < latinShareInRussian {
primary, secondary = secondary, primary
}
}
if len(primary) == 0 {
primary = secondary
}
if len(primary) == 0 {
return uuid.Nil, ErrNoRobotAvailable
}
return primary[rand.IntN(len(primary))], nil
}
// poolIDs returns a snapshot of the whole pool (both languages) for the driver scan,
// which is variant-agnostic — it acts on every robot's active games.
func (s *Service) poolIDs() []uuid.UUID {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]uuid.UUID(nil), s.pool...)
}
// externalID is the stable robot identity for a pool name.
func externalID(name string) string {
return "robot-" + name
ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU))
ids = append(ids, s.poolEN...)
ids = append(ids, s.poolRU...)
return ids
}
+55 -16
View File
@@ -23,17 +23,27 @@ const (
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
playToWinPercent = 40
// delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the
// right-skewed distribution (short delays frequent). With skew 3.5 the median
// is about 10 minutes and the mean about 20, with a tail out to the maximum.
delayMinMinutes = 2.0
delayMaxMinutes = 90.0
delaySkew = 3.5
// The robot's think time depends on how far the game has progressed: early moves
// are quick and late moves can be long (endgame deliberation). The delay is drawn
// from a band that interpolates with the move count from [delayEarlyLoMinutes,
// delayEarlyHiMinutes] at the first move to [delayLateLoMinutes, delayLateHiMinutes]
// by avgGameMoves, then right-skewed by delaySkew (a larger exponent concentrates
// delays near the band's floor — an active player). The result is clamped to
// [delayHardMinMinutes, delayHardMaxMinutes]. The numbers are deliberate estimates,
// to be retuned once real play statistics arrive (docs/ARCHITECTURE.md §7).
delayEarlyLoMinutes = 1.0
delayEarlyHiMinutes = 5.0
delayLateLoMinutes = 10.0
delayLateHiMinutes = 90.0
delaySkew = 4.0
avgGameMoves = 28.0
delayHardMinMinutes = 1.0
delayHardMaxMinutes = 90.0
// nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot
// answers a daytime nudge on its turn.
nudgeReplyMinMinutes = 2.0
nudgeReplyMaxMinutes = 10.0
// nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's
// lower band (delayBand's lo), within which the robot answers a daytime nudge on
// its turn — so a nudged robot replies near the floor of its think time.
nudgeReplySpreadMinutes = 5.0
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
// (opponent-anchored, drifted) local time: it makes no move and sends no nudge
@@ -104,19 +114,48 @@ func playToWin(seed int64) bool {
return mix(seed, "win")%100 < playToWinPercent
}
// moveDelay is the robot's think time for the move at moveCount, sampled from the
// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes).
// delayBand returns the lower and upper bounds, in minutes, of the move-delay band
// for the move at moveCount. It interpolates linearly with game progress (the move
// count over avgGameMoves, capped at 1): early moves sit in a short band and late
// moves in a long one.
func delayBand(moveCount int) (lo, hi float64) {
p := float64(moveCount) / avgGameMoves
if p > 1 {
p = 1
}
lo = delayEarlyLoMinutes + (delayLateLoMinutes-delayEarlyLoMinutes)*p
hi = delayEarlyHiMinutes + (delayLateHiMinutes-delayEarlyHiMinutes)*p
return lo, hi
}
// moveDelay is the robot's think time for the move at moveCount: a right-skewed
// sample from the move's delayBand, clamped to the hard bounds. The skew (delaySkew
// > 1) makes short delays frequent and long ones rare, with a tail to the band's top.
func moveDelay(seed int64, moveCount int) time.Duration {
lo, hi := delayBand(moveCount)
u := unitFloat(mix(seed, "delay", moveCount))
mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew)
return time.Duration(mins * float64(time.Minute))
return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew))
}
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes).
// moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes],
// where lo is the move's lower band — so a nudge pulls the move in near the floor of
// the robot's think time.
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
lo, _ := delayBand(moveCount)
u := unitFloat(mix(seed, "nudge", moveCount))
mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
}
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
// bounds so an out-of-range band can never produce an absurd think time.
func clampMinutes(mins float64) time.Duration {
if mins < delayHardMinMinutes {
mins = delayHardMinMinutes
}
if mins > delayHardMaxMinutes {
mins = delayHardMaxMinutes
}
return time.Duration(mins * float64(time.Minute))
}
+37 -10
View File
@@ -27,14 +27,14 @@ func TestPlayToWinDistribution(t *testing.T) {
}
}
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in
// [2min, 90min) and is reproducible for a (seed, moveCount).
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard
// bounds [1min, 90min] and is reproducible for a (seed, moveCount).
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
for seed := int64(1); seed <= 200; seed++ {
for mc := 0; mc < 50; mc++ {
d := moveDelay(seed, mc)
if d < 2*time.Minute || d >= 90*time.Minute {
t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc)
if d < 1*time.Minute || d > 90*time.Minute {
t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc)
}
if moveDelay(seed, mc) != d {
t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
@@ -43,22 +43,49 @@ func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
}
}
// TestMoveDelaySkew checks the distribution is right-skewed with the intended
// ~10-minute median: most delays are short, the mean sits above the median.
// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the
// first move lives in the short [1,5]min band, a late move in the long [10,90]min
// band, so the median think time rises with the move count.
func TestMoveDelayGrowsWithMoveCount(t *testing.T) {
median := func(mc int) float64 {
const n = 4000
xs := make([]float64, n)
for s := 0; s < n; s++ {
xs[s] = moveDelay(int64(s+1), mc).Minutes()
}
sort.Float64s(xs)
return xs[n/2]
}
for s := int64(1); s <= 500; s++ {
if d := moveDelay(s, 0).Minutes(); d < 1 || d > 5 {
t.Fatalf("first-move delay %.2f out of [1,5] for seed %d", d, s)
}
if d := moveDelay(s, 40).Minutes(); d < 10 || d > 90 {
t.Fatalf("late-move delay %.2f out of [10,90] for seed %d", d, s)
}
}
if early, late := median(0), median(30); early >= late {
t.Errorf("median should grow with move count: move0=%.1f move30=%.1f", early, late)
}
}
// TestMoveDelaySkew checks the late-game distribution is right-skewed at a fixed move
// count: short delays are frequent (median near the band floor) and the mean sits
// above the median, with a tail toward the cap.
func TestMoveDelaySkew(t *testing.T) {
const n = 20000
mins := make([]float64, 0, n)
var sum float64
for mc := 0; mc < n; mc++ {
m := moveDelay(42, mc).Minutes()
for s := 0; s < n; s++ {
m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90]
mins = append(mins, m)
sum += m
}
sort.Float64s(mins)
median := mins[n/2]
mean := sum / float64(n)
if median < 7 || median > 13 {
t.Errorf("median delay = %.1f min, want ~10 (7-13)", median)
if median < 12 || median > 20 {
t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median)
}
if mean <= median {
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
+1
View File
@@ -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"},
+3 -1
View File
@@ -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):
@@ -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)
}