Stage 17: test-contour verification & defect fixes #19
@@ -12,7 +12,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"github.com/go-jet/jet/v2/qrm"
|
"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{})
|
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
|
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
||||||
// identity. On first contact only, it seeds the new account's preferred language
|
// 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
|
// 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.
|
// 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) {
|
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
||||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
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
|
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||||
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
|
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
||||||
// username (capped to maxDisplayName runes).
|
// 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 {
|
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||||
var seed provisionSeed
|
var seed provisionSeed
|
||||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||||
seed.preferredLanguage = lang
|
seed.preferredLanguage = lang
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(firstName)
|
name := sanitizeDisplayName(firstName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = strings.TrimSpace(username)
|
name = sanitizeDisplayName(username)
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
if name == "" {
|
||||||
name = string([]rune(name)[:maxDisplayName])
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
}
|
}
|
||||||
seed.displayName = name
|
seed.displayName = name
|
||||||
return seed
|
return seed
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
@@ -110,6 +112,39 @@ func ValidateDisplayName(raw string) (string, error) {
|
|||||||
return name, nil
|
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
|
// validateAwayWindow checks that the daily away window's duration, wrapping across
|
||||||
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
||||||
// "no away time" and is allowed.
|
// "no away time" and is allowed.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -8,7 +9,8 @@ import (
|
|||||||
|
|
||||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
// 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) {
|
func TestTelegramSeed(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
@@ -21,8 +23,11 @@ func TestTelegramSeed(t *testing.T) {
|
|||||||
"empty language": {"", "neo", "Neo", "", "Neo"},
|
"empty language": {"", "neo", "Neo", "", "Neo"},
|
||||||
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
||||||
"username fallback": {"en", "handle", "", "en", "handle"},
|
"username fallback": {"en", "handle", "", "en", "handle"},
|
||||||
"both empty": {"en", "", "", "en", ""},
|
|
||||||
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
|
"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 {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
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
|
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
|
||||||
// maxDisplayName runes (counted in runes, not bytes).
|
// maxDisplayName runes (counted in runes, not bytes).
|
||||||
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
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 { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||||
.actions form { margin: 0; }
|
.actions form { margin: 0; }
|
||||||
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
.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); }
|
||||||
|
|||||||
@@ -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}}
|
{{else}}<p class="note">no statistics</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
<section class="panel"><h2>Identities</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<table class="list">
|
<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>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -11,9 +11,10 @@
|
|||||||
<td>{{.Kind}}</td>
|
<td>{{.Kind}}</td>
|
||||||
<td>{{.Language}}</td>
|
<td>{{.Language}}</td>
|
||||||
<td>{{.CreatedAt}}</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>
|
</tr>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package adminconsole
|
package adminconsole
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
// The *View types are the display models the gin handlers fill and the templates
|
// 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
|
// render. Time values are pre-formatted to strings by the handlers so the
|
||||||
// templates stay logic-free.
|
// templates stay logic-free.
|
||||||
@@ -50,7 +52,8 @@ type UsersView struct {
|
|||||||
Pager Pager
|
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 {
|
type UserRow struct {
|
||||||
ID string
|
ID string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
@@ -58,6 +61,10 @@ type UserRow struct {
|
|||||||
Language string
|
Language string
|
||||||
Guest bool
|
Guest bool
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
|
HasMoveStats bool
|
||||||
|
MoveMin string
|
||||||
|
MoveAvg string
|
||||||
|
MoveMax string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserDetailView is one account with its stats, identities and recent games.
|
// UserDetailView is one account with its stats, identities and recent games.
|
||||||
@@ -80,6 +87,9 @@ type UserDetailView struct {
|
|||||||
Games []GameRow
|
Games []GameRow
|
||||||
TelegramID string
|
TelegramID string
|
||||||
ConnectorEnabled bool
|
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.
|
// StatsRow is an account's lifetime statistics.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const meterName = "scrabble/backend/game"
|
|||||||
type gameMetrics struct {
|
type gameMetrics struct {
|
||||||
replay metric.Float64Histogram
|
replay metric.Float64Histogram
|
||||||
validate metric.Float64Histogram
|
validate metric.Float64Histogram
|
||||||
|
moveDur metric.Float64Histogram
|
||||||
started metric.Int64Counter
|
started metric.Int64Counter
|
||||||
abandoned metric.Int64Counter
|
abandoned metric.Int64Counter
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics {
|
|||||||
return &gameMetrics{
|
return &gameMetrics{
|
||||||
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
|
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)."),
|
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."),
|
started: counter(meter, "games_started_total", "Games created and started."),
|
||||||
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
|
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))
|
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.
|
// recordStarted counts one started game of variant.
|
||||||
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
||||||
m.started.Add(ctx, 1, variantAttr(v))
|
m.started.Add(ctx, 1, variantAttr(v))
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func TestGameMetrics(t *testing.T) {
|
|||||||
m.recordAbandoned(ctx, engine.VariantErudit)
|
m.recordAbandoned(ctx, engine.VariantErudit)
|
||||||
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
||||||
m.recordValidate(ctx, engine.VariantRussianScrabble, 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
|
var rm metricdata.ResourceMetrics
|
||||||
if err := reader.Collect(ctx, &rm); err != nil {
|
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 {
|
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
|
||||||
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
|
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
|
// counterByAttr sums the int64 counter named name, grouped by the value of the
|
||||||
|
|||||||
@@ -226,6 +226,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveResult{}, err
|
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
|
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
|
// 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
|
// every seat — including the actor's own account, so the mover's other devices (and
|
||||||
// is still active. Delivery is best-effort (notify.Publisher never blocks).
|
// 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) {
|
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
|
||||||
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
||||||
for _, s := range post.Seats {
|
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))
|
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
||||||
}
|
}
|
||||||
if post.Status == StatusActive {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,13 +82,16 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
|||||||
if err := r.EnsurePool(ctx); err != nil {
|
if err := r.EnsurePool(ctx); err != nil {
|
||||||
t.Fatalf("ensure pool (idempotent): %v", err)
|
t.Fatalf("ensure pool (idempotent): %v", err)
|
||||||
}
|
}
|
||||||
id, err := r.Pick()
|
id, err := r.Pick(engine.VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pick: %v", err)
|
t.Fatalf("pick: %v", err)
|
||||||
}
|
}
|
||||||
if !isRobotAccount(t, id) {
|
if !isRobotAccount(t, id) {
|
||||||
t.Errorf("picked account %s is not a robot identity", 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)
|
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get robot account: %v", err)
|
t.Fatalf("get robot account: %v", err)
|
||||||
@@ -109,7 +112,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
|
|||||||
if err := robots.EnsurePool(ctx); err != nil {
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
t.Fatalf("ensure pool: %v", err)
|
t.Fatalf("ensure pool: %v", err)
|
||||||
}
|
}
|
||||||
robotID, err := robots.Pick()
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pick: %v", err)
|
t.Fatalf("pick: %v", err)
|
||||||
}
|
}
|
||||||
@@ -210,7 +213,7 @@ func TestRobotProactiveNudge(t *testing.T) {
|
|||||||
if err := robots.EnsurePool(ctx); err != nil {
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
t.Fatalf("ensure pool: %v", err)
|
t.Fatalf("ensure pool: %v", err)
|
||||||
}
|
}
|
||||||
robotID, err := robots.Pick()
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pick: %v", err)
|
t.Fatalf("pick: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
|||||||
if err := robots.EnsurePool(ctx); err != nil {
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
t.Fatalf("ensure pool: %v", err)
|
t.Fatalf("ensure pool: %v", err)
|
||||||
}
|
}
|
||||||
robotID, err := robots.Pick()
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pick: %v", err)
|
t.Fatalf("pick: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"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
|
// auto-match. robot.Service satisfies it; it returns an error when no robot is
|
||||||
// available so the matchmaker can defer substitution.
|
// available so the matchmaker can defer substitution.
|
||||||
type RobotProvider interface {
|
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
|
// Blocker reports whether two accounts have a block between them (either
|
||||||
|
|||||||
@@ -197,12 +197,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
|||||||
}
|
}
|
||||||
var subs []sub
|
var subs []sub
|
||||||
for _, acc := range due {
|
for _, acc := range due {
|
||||||
robotID, err := m.robots.Pick()
|
variant := m.queued[acc]
|
||||||
|
robotID, err := m.robots.Pick(variant)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.log.Warn("robot substitution deferred", zap.Error(err))
|
m.log.Warn("robot substitution deferred", zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
variant := m.queued[acc]
|
|
||||||
m.removeLocked(acc, variant)
|
m.removeLocked(acc, variant)
|
||||||
seats := []uuid.UUID{acc, robotID}
|
seats := []uuid.UUID{acc, robotID}
|
||||||
if m.rng.Intn(2) == 0 {
|
if m.rng.Intn(2) == 0 {
|
||||||
|
|||||||
@@ -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
|
// 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 {
|
type fakeRobots struct {
|
||||||
id uuid.UUID
|
id uuid.UUID
|
||||||
err error
|
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 {
|
if f.err != nil {
|
||||||
return uuid.Nil, f.err
|
return uuid.Nil, f.err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,13 +55,6 @@ type Nudger interface {
|
|||||||
LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error)
|
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.
|
// Config configures the robot subsystem.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// DriveInterval is how often the driver scans for robot turns. Sourced from
|
// DriveInterval is how often the driver scans for robot turns. Sourced from
|
||||||
@@ -92,7 +85,8 @@ type Service struct {
|
|||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
pool []uuid.UUID
|
poolEN []uuid.UUID
|
||||||
|
poolRU []uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a robot Service. games and social are the domain seams it
|
// 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
|
// EnsurePool idempotently provisions the robot accounts (one per slot of each
|
||||||
// ids as the pool. Each robot is a durable account bound to a robot identity,
|
// language's composed name pool) and records their ids. Each robot is a durable
|
||||||
// with chat and friend requests blocked so it never engages socially
|
// account bound to a stable, index-keyed robot identity, with chat and friend
|
||||||
// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary
|
// requests blocked so it never engages socially (docs/ARCHITECTURE.md §7). It is a
|
||||||
// registry: a failure fails the boot.
|
// startup dependency, like the dictionary registry: a failure fails the boot.
|
||||||
func (s *Service) EnsurePool(ctx context.Context) error {
|
func (s *Service) EnsurePool(ctx context.Context) error {
|
||||||
ids := make([]uuid.UUID, 0, len(robotNames))
|
en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN())
|
||||||
for _, name := range robotNames {
|
|
||||||
acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("robot: provision %q: %w", name, err)
|
return err
|
||||||
}
|
}
|
||||||
if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests {
|
ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU())
|
||||||
if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
|
if err != nil {
|
||||||
DisplayName: name,
|
return err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.pool = ids
|
s.poolEN, s.poolRU = en, ru
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick returns a random robot account from the pool, for the matchmaker to
|
// provisionPool provisions one durable robot account per name and returns their ids
|
||||||
// substitute into an auto-match. It satisfies lobby.RobotProvider.
|
// in order. The identity is keyed by language and slot index (stable across restarts
|
||||||
func (s *Service) Pick() (uuid.UUID, error) {
|
// and independent of the composed display name); account.ProvisionRobot sets the
|
||||||
s.mu.RLock()
|
// display name and social blocks and is idempotent, so EnsurePool can run every boot.
|
||||||
defer s.mu.RUnlock()
|
func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) {
|
||||||
if len(s.pool) == 0 {
|
ids := make([]uuid.UUID, 0, len(names))
|
||||||
return uuid.Nil, ErrNoRobotAvailable
|
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)
|
||||||
}
|
}
|
||||||
return s.pool[rand.IntN(len(s.pool))], nil
|
ids = append(ids, acc.ID)
|
||||||
|
}
|
||||||
|
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 {
|
func (s *Service) poolIDs() []uuid.UUID {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
return append([]uuid.UUID(nil), s.pool...)
|
ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU))
|
||||||
}
|
ids = append(ids, s.poolEN...)
|
||||||
|
ids = append(ids, s.poolRU...)
|
||||||
// externalID is the stable robot identity for a pool name.
|
return ids
|
||||||
func externalID(name string) string {
|
|
||||||
return "robot-" + name
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,17 +23,27 @@ const (
|
|||||||
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
|
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
|
||||||
playToWinPercent = 40
|
playToWinPercent = 40
|
||||||
|
|
||||||
// delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the
|
// The robot's think time depends on how far the game has progressed: early moves
|
||||||
// right-skewed distribution (short delays frequent). With skew 3.5 the median
|
// are quick and late moves can be long (endgame deliberation). The delay is drawn
|
||||||
// is about 10 minutes and the mean about 20, with a tail out to the maximum.
|
// from a band that interpolates with the move count from [delayEarlyLoMinutes,
|
||||||
delayMinMinutes = 2.0
|
// delayEarlyHiMinutes] at the first move to [delayLateLoMinutes, delayLateHiMinutes]
|
||||||
delayMaxMinutes = 90.0
|
// by avgGameMoves, then right-skewed by delaySkew (a larger exponent concentrates
|
||||||
delaySkew = 3.5
|
// 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
|
// nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's
|
||||||
// answers a daytime nudge on its turn.
|
// lower band (delayBand's lo), within which the robot answers a daytime nudge on
|
||||||
nudgeReplyMinMinutes = 2.0
|
// its turn — so a nudged robot replies near the floor of its think time.
|
||||||
nudgeReplyMaxMinutes = 10.0
|
nudgeReplySpreadMinutes = 5.0
|
||||||
|
|
||||||
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
|
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
|
||||||
// (opponent-anchored, drifted) local time: it makes no move and sends no nudge
|
// (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
|
return mix(seed, "win")%100 < playToWinPercent
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveDelay is the robot's think time for the move at moveCount, sampled from the
|
// delayBand returns the lower and upper bounds, in minutes, of the move-delay band
|
||||||
// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes).
|
// 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 {
|
func moveDelay(seed int64, moveCount int) time.Duration {
|
||||||
|
lo, hi := delayBand(moveCount)
|
||||||
u := unitFloat(mix(seed, "delay", moveCount))
|
u := unitFloat(mix(seed, "delay", moveCount))
|
||||||
mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew)
|
return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew))
|
||||||
return time.Duration(mins * float64(time.Minute))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
|
// 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 {
|
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
|
||||||
|
lo, _ := delayBand(moveCount)
|
||||||
u := unitFloat(mix(seed, "nudge", 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))
|
return time.Duration(mins * float64(time.Minute))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ func TestPlayToWinDistribution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in
|
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard
|
||||||
// [2min, 90min) and is reproducible for a (seed, moveCount).
|
// bounds [1min, 90min] and is reproducible for a (seed, moveCount).
|
||||||
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
|
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
|
||||||
for seed := int64(1); seed <= 200; seed++ {
|
for seed := int64(1); seed <= 200; seed++ {
|
||||||
for mc := 0; mc < 50; mc++ {
|
for mc := 0; mc < 50; mc++ {
|
||||||
d := moveDelay(seed, mc)
|
d := moveDelay(seed, mc)
|
||||||
if d < 2*time.Minute || d >= 90*time.Minute {
|
if d < 1*time.Minute || d > 90*time.Minute {
|
||||||
t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc)
|
t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc)
|
||||||
}
|
}
|
||||||
if moveDelay(seed, mc) != d {
|
if moveDelay(seed, mc) != d {
|
||||||
t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
|
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
|
// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the
|
||||||
// ~10-minute median: most delays are short, the mean sits above the median.
|
// 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) {
|
func TestMoveDelaySkew(t *testing.T) {
|
||||||
const n = 20000
|
const n = 20000
|
||||||
mins := make([]float64, 0, n)
|
mins := make([]float64, 0, n)
|
||||||
var sum float64
|
var sum float64
|
||||||
for mc := 0; mc < n; mc++ {
|
for s := 0; s < n; s++ {
|
||||||
m := moveDelay(42, mc).Minutes()
|
m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90]
|
||||||
mins = append(mins, m)
|
mins = append(mins, m)
|
||||||
sum += m
|
sum += m
|
||||||
}
|
}
|
||||||
sort.Float64s(mins)
|
sort.Float64s(mins)
|
||||||
median := mins[n/2]
|
median := mins[n/2]
|
||||||
mean := sum / float64(n)
|
mean := sum / float64(n)
|
||||||
if median < 7 || median > 13 {
|
if median < 12 || median > 20 {
|
||||||
t.Errorf("median delay = %.1f min, want ~10 (7-13)", median)
|
t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median)
|
||||||
}
|
}
|
||||||
if mean <= median {
|
if mean <= median {
|
||||||
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
|
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func TestStatusForError(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
|
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
|
||||||
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
|
"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"},
|
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
||||||
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
||||||
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
||||||
|
|||||||
@@ -148,8 +148,10 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusNotFound, "not_found"
|
return http.StatusNotFound, "not_found"
|
||||||
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
|
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
|
||||||
return http.StatusForbidden, "not_a_player"
|
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"
|
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):
|
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
|
||||||
return http.StatusConflict, "game_finished"
|
return http.StatusConflict, "game_finished"
|
||||||
case errors.Is(err, game.ErrGameActive):
|
case errors.Is(err, game.ErrGameActive):
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
|
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
|
||||||
|
ids := make([]uuid.UUID, 0, len(accs))
|
||||||
for _, a := range accs {
|
for _, a := range accs {
|
||||||
kind := "registered"
|
kind := "registered"
|
||||||
if a.IsGuest {
|
if a.IsGuest {
|
||||||
@@ -91,6 +92,17 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
|
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
|
||||||
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
|
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)
|
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))
|
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)
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export const en = {
|
|||||||
'lang.ru': 'Русский',
|
'lang.ru': 'Русский',
|
||||||
|
|
||||||
'error.not_your_turn': "It is not your turn.",
|
'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.illegal_play': 'That is not a legal play.',
|
||||||
'error.hint_unavailable': 'No hints available.',
|
'error.hint_unavailable': 'No hints available.',
|
||||||
'error.no_hint_available': 'No options with your letters.',
|
'error.no_hint_available': 'No options with your letters.',
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'lang.ru': 'Русский',
|
'lang.ru': 'Русский',
|
||||||
|
|
||||||
'error.not_your_turn': 'Сейчас не ваш ход.',
|
'error.not_your_turn': 'Сейчас не ваш ход.',
|
||||||
|
'error.nudge_own_turn': 'Сейчас ваш ход — некого торопить.',
|
||||||
'error.illegal_play': 'Это недопустимый ход.',
|
'error.illegal_play': 'Это недопустимый ход.',
|
||||||
'error.hint_unavailable': 'Подсказки недоступны.',
|
'error.hint_unavailable': 'Подсказки недоступны.',
|
||||||
'error.no_hint_available': 'Нет вариантов с вашим набором.',
|
'error.no_hint_available': 'Нет вариантов с вашим набором.',
|
||||||
|
|||||||
Reference in New Issue
Block a user