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

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

Move timing

+

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

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

Identities

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

Users

{{with .Data}}
KindExternal IDConfirmedCreated
- + {{range .Items}} @@ -11,9 +11,10 @@ +{{if .HasMoveStats}}{{else}}{{end}} {{else}} - + {{end}}
AccountDisplay nameKindLangCreated
AccountDisplay nameKindLangCreatedMove minavgmax
{{.Kind}} {{.Language}} {{.CreatedAt}}{{.MoveMin}}{{.MoveAvg}}{{.MoveMax}}
no users
no users
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index d05ddc6..9874d50 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -1,5 +1,7 @@ package adminconsole +import "html/template" + // The *View types are the display models the gin handlers fill and the templates // render. Time values are pre-formatted to strings by the handlers so the // templates stay logic-free. @@ -50,14 +52,19 @@ type UsersView struct { Pager Pager } -// UserRow is one account row in the list. +// UserRow is one account row in the list. MoveMin/Avg/Max are the account's +// pre-formatted move-duration summary (empty when it has no timed move). type UserRow struct { - ID string - DisplayName string - Kind string - Language string - Guest bool - CreatedAt string + ID string + DisplayName string + Kind string + Language string + Guest bool + CreatedAt string + HasMoveStats bool + MoveMin string + MoveAvg string + MoveMax string } // UserDetailView is one account with its stats, identities and recent games. @@ -80,6 +87,9 @@ type UserDetailView struct { Games []GameRow TelegramID string ConnectorEnabled bool + // MoveChart is the pre-rendered inline SVG of the account's per-move-number think + // time (min/mean/max), empty when the account has no timed move. + MoveChart template.HTML } // StatsRow is an account's lifetime statistics. diff --git a/backend/internal/game/analytics.go b/backend/internal/game/analytics.go new file mode 100644 index 0000000..3b0a301 --- /dev/null +++ b/backend/internal/game/analytics.go @@ -0,0 +1,116 @@ +package game + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// A move's "duration" is the think time from the previous move's commit (the moment +// the turn started) to this move's commit. Only play/pass/exchange moves count; +// timeouts and resignations are not think time. The very first move of a game has no +// previous move, so its baseline is the game's creation time. The figures are derived +// from the move journal (game_moves.created_at), so no schema change is needed. +// +// timedMovesCTE is the shared subquery yielding (account, game, ordinal, seconds) for +// every timed move; the two reports aggregate it differently. +const timedMovesCTE = ` + SELECT gp.account_id AS aid, + m.game_id AS gid, + ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq) AS ord, + EXTRACT(EPOCH FROM (m.created_at - COALESCE(prev.created_at, g.created_at))) AS secs + FROM backend.game_moves m + JOIN backend.games g ON g.game_id = m.game_id + LEFT JOIN backend.game_moves prev ON prev.game_id = m.game_id AND prev.seq = m.seq - 1 + JOIN backend.game_players gp ON gp.game_id = m.game_id AND gp.seat = m.seat + WHERE m.action IN ('play', 'pass', 'exchange')` + +// MoveDurationStat is the min, max and mean per-move think time (in seconds) for an +// account across all its games, with the number of timed moves counted. +type MoveDurationStat struct { + MinSecs float64 + MaxSecs float64 + AvgSecs float64 + Moves int +} + +// MoveDurationStats returns the move-duration summary for each of accountIDs that has +// at least one timed move; accounts with none are absent from the map. It powers the +// admin user-list columns. The scan over the journal is acceptable for the low-traffic +// console; per-human analysis is the authoritative use (the live metric aggregates all +// seats including robots). +func (s *Store) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) { + if len(accountIDs) == 0 { + return map[uuid.UUID]MoveDurationStat{}, nil + } + q := `WITH d AS (` + timedMovesCTE + `) +SELECT aid, MIN(secs), MAX(secs), AVG(secs), COUNT(*) FROM d WHERE aid = ANY($1::uuid[]) GROUP BY aid` + rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(accountIDs)) + if err != nil { + return nil, fmt.Errorf("game: move-duration stats: %w", err) + } + defer rows.Close() + out := make(map[uuid.UUID]MoveDurationStat, len(accountIDs)) + for rows.Next() { + var id uuid.UUID + var st MoveDurationStat + if err := rows.Scan(&id, &st.MinSecs, &st.MaxSecs, &st.AvgSecs, &st.Moves); err != nil { + return nil, fmt.Errorf("game: scan move-duration stat: %w", err) + } + out[id] = st + } + return out, rows.Err() +} + +// OrdinalDuration is the min/max/mean think time (seconds) at an account's k-th move +// (Ordinal) across all its games. +type OrdinalDuration struct { + Ordinal int + MinSecs float64 + MaxSecs float64 + AvgSecs float64 +} + +// MoveDurationByOrdinal returns the account's per-move-number think-time summary, +// ordered by move number, for the admin user-detail chart. The ordinal counts the +// account's own moves within each game (its 1st, 2nd, … move). +func (s *Store) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) { + q := `WITH d AS (` + timedMovesCTE + ` AND gp.account_id = $1) +SELECT ord, MIN(secs), MAX(secs), AVG(secs) FROM d GROUP BY ord ORDER BY ord` + rows, err := s.db.QueryContext(ctx, q, accountID) + if err != nil { + return nil, fmt.Errorf("game: move-duration by ordinal: %w", err) + } + defer rows.Close() + var out []OrdinalDuration + for rows.Next() { + var od OrdinalDuration + if err := rows.Scan(&od.Ordinal, &od.MinSecs, &od.MaxSecs, &od.AvgSecs); err != nil { + return nil, fmt.Errorf("game: scan ordinal duration: %w", err) + } + out = append(out, od) + } + return out, rows.Err() +} + +// uuidArrayLiteral renders ids as a Postgres array literal ("{u1,u2,…}") for an +// ANY($1::uuid[]) parameter. UUIDs are fixed-format, so the literal is injection-safe. +func uuidArrayLiteral(ids []uuid.UUID) string { + ss := make([]string, len(ids)) + for i, id := range ids { + ss[i] = id.String() + } + return "{" + strings.Join(ss, ",") + "}" +} + +// MoveDurationStats exposes the store report to the admin console handlers. +func (svc *Service) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) { + return svc.store.MoveDurationStats(ctx, accountIDs) +} + +// MoveDurationByOrdinal exposes the per-move-number report to the admin console. +func (svc *Service) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) { + return svc.store.MoveDurationByOrdinal(ctx, accountID) +} diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go new file mode 100644 index 0000000..03f6509 --- /dev/null +++ b/backend/internal/game/emit_test.go @@ -0,0 +1,52 @@ +package game + +import ( + "slices" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" + "scrabble/backend/internal/notify" +) + +// recordingPublisher captures every published intent for assertions. +type recordingPublisher struct{ intents []notify.Intent } + +func (p *recordingPublisher) Publish(in ...notify.Intent) { p.intents = append(p.intents, in...) } + +// TestEmitMoveNotifiesActor checks a committed move sends opponent_moved to every +// seat — including the actor's own account, so the mover's other devices refresh — +// and your_turn only to the next mover. +func TestEmitMoveNotifiesActor(t *testing.T) { + actor, opp := uuid.New(), uuid.New() + pub := &recordingPublisher{} + svc := &Service{pub: pub} + g := Game{ + ID: uuid.New(), + Status: StatusActive, + ToMove: 1, + TurnStartedAt: time.Now(), + TurnTimeout: time.Hour, + Seats: []Seat{{Seat: 0, AccountID: actor}, {Seat: 1, AccountID: opp}}, + } + svc.emitMove(g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Score: 10, Total: 10}) + + kinds := map[uuid.UUID][]string{} + for _, in := range pub.intents { + kinds[in.UserID] = append(kinds[in.UserID], in.Kind) + } + if !slices.Contains(kinds[actor], notify.KindOpponentMoved) { + t.Errorf("actor should get opponent_moved, got %v", kinds[actor]) + } + if !slices.Contains(kinds[opp], notify.KindOpponentMoved) { + t.Errorf("opponent should get opponent_moved, got %v", kinds[opp]) + } + if !slices.Contains(kinds[opp], notify.KindYourTurn) { + t.Errorf("next mover should get your_turn, got %v", kinds[opp]) + } + if slices.Contains(kinds[actor], notify.KindYourTurn) { + t.Errorf("actor is not next to move, should not get your_turn") + } +} diff --git a/backend/internal/game/metrics.go b/backend/internal/game/metrics.go index 8a59185..b9596d3 100644 --- a/backend/internal/game/metrics.go +++ b/backend/internal/game/metrics.go @@ -22,6 +22,7 @@ const meterName = "scrabble/backend/game" type gameMetrics struct { replay metric.Float64Histogram validate metric.Float64Histogram + moveDur metric.Float64Histogram started metric.Int64Counter abandoned metric.Int64Counter } @@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics { return &gameMetrics{ replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."), validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."), + moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."), started: counter(meter, "games_started_total", "Games created and started."), abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."), } @@ -75,6 +77,30 @@ func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, star m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v)) } +// recordMoveDuration records how long a seat spent on a committed move, attributed by +// variant and the game phase derived from moveCount. A non-positive duration (a clock +// skew or a move with no recorded turn start) is dropped. +func (m *gameMetrics) recordMoveDuration(ctx context.Context, v engine.Variant, moveCount int, d time.Duration) { + if d <= 0 { + return + } + m.moveDur.Record(ctx, d.Seconds(), + metric.WithAttributes(attribute.String("variant", v.String()), attribute.String("phase", phaseOf(moveCount)))) +} + +// phaseOf buckets a move ordinal into the game phase used as a metric attribute. The +// thresholds reflect a typical ~28-move game (docs/ARCHITECTURE.md §7). +func phaseOf(moveCount int) string { + switch { + case moveCount <= 8: + return "opening" + case moveCount <= 20: + return "middle" + default: + return "endgame" + } +} + // recordStarted counts one started game of variant. func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) { m.started.Add(ctx, 1, variantAttr(v)) diff --git a/backend/internal/game/metrics_test.go b/backend/internal/game/metrics_test.go index dad8b97..cd1d5c5 100644 --- a/backend/internal/game/metrics_test.go +++ b/backend/internal/game/metrics_test.go @@ -26,6 +26,8 @@ func TestGameMetrics(t *testing.T) { m.recordAbandoned(ctx, engine.VariantErudit) m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond)) m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond)) + m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second) + m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped var rm metricdata.ResourceMetrics if err := reader.Collect(ctx, &rm); err != nil { @@ -45,6 +47,19 @@ func TestGameMetrics(t *testing.T) { if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 { t.Errorf("game_move_validate_duration observations = %d, want 1", c) } + if c := histogramCount(t, rm, "game_move_duration"); c != 1 { + t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c) + } +} + +// TestPhaseOf checks the move-ordinal to phase bucketing. +func TestPhaseOf(t *testing.T) { + cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"} + for mc, want := range cases { + if got := phaseOf(mc); got != want { + t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want) + } + } } // counterByAttr sums the int64 counter named name, grouped by the value of the diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 0b220aa..780bf8e 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -226,6 +226,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, if err != nil { return MoveResult{}, err } + // Record the seat's think time (turn start to commit) for the move-duration + // metric; the timeout path commits separately and is excluded by design. + svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt)) return MoveResult{Move: rec, Game: post}, nil } @@ -287,14 +290,15 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game } // emitMove publishes the live events for a just-committed move: opponent_moved to -// every seat other than the actor, and your_turn to the next mover while the game -// is still active. Delivery is best-effort (notify.Publisher never blocks). +// every seat — including the actor's own account, so the mover's other devices (and +// their lobby) refresh too — and your_turn to the next mover while the game is still +// active. opponent_moved is in-app only (the gateway never turns it into an +// out-of-app push), so the actor is not notified out of band about their own move. +// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each +// event out to all of the recipient's live streams. func (svc *Service) emitMove(post Game, rec engine.MoveRecord) { intents := make([]notify.Intent, 0, len(post.Seats)+1) for _, s := range post.Seats { - if s.Seat == rec.Player { - continue - } intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total)) } if post.Status == StatusActive { diff --git a/backend/internal/inttest/analytics_test.go b/backend/internal/inttest/analytics_test.go new file mode 100644 index 0000000..4697dd8 --- /dev/null +++ b/backend/internal/inttest/analytics_test.go @@ -0,0 +1,81 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/game" +) + +// TestMoveDurationAnalytics seeds a game with crafted move timestamps and checks the +// admin-console move-duration reports compute the think time (gap to the previous +// move, the first move measured from game creation) correctly, per account and per +// the account's move ordinal. +func TestMoveDurationAnalytics(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + a, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision A: %v", err) + } + b, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision B: %v", err) + } + + gid := uuid.New() + t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + if _, err := testDB.ExecContext(ctx, + `INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at) + VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil { + t.Fatalf("insert game: %v", err) + } + if _, err := testDB.ExecContext(ctx, + `INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil { + t.Fatalf("insert seats: %v", err) + } + // seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200. + moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}} + for _, m := range moves { + if _, err := testDB.ExecContext(ctx, + `INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`, + gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil { + t.Fatalf("insert move %d: %v", m.seq, err) + } + } + + store := game.NewStore(testDB) + stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID}) + if err != nil { + t.Fatalf("stats: %v", err) + } + if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 { + t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa) + } + if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 { + t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb) + } + + byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID) + if err != nil { + t.Fatalf("by ordinal: %v", err) + } + want := []game.OrdinalDuration{ + {Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60}, + {Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50}, + } + if len(byOrd) != len(want) { + t.Fatalf("by ordinal = %+v, want %+v", byOrd, want) + } + for i, w := range want { + if byOrd[i] != w { + t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w) + } + } +} diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index a212484..33efe14 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -82,13 +82,16 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) { if err := r.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool (idempotent): %v", err) } - id, err := r.Pick() + id, err := r.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } if !isRobotAccount(t, id) { t.Errorf("picked account %s is not a robot identity", id) } + if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) { + t.Errorf("russian pick = (%s, %v), want a robot account", ru, err) + } acc, err := account.NewStore(testDB).GetByID(ctx, id) if err != nil { t.Fatalf("get robot account: %v", err) @@ -109,7 +112,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) { if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } - robotID, err := robots.Pick() + robotID, err := robots.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } @@ -210,7 +213,7 @@ func TestRobotProactiveNudge(t *testing.T) { if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } - robotID, err := robots.Pick() + robotID, err := robots.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } diff --git a/backend/internal/inttest/stage6_test.go b/backend/internal/inttest/stage6_test.go index bade281..6c3ea48 100644 --- a/backend/internal/inttest/stage6_test.go +++ b/backend/internal/inttest/stage6_test.go @@ -38,7 +38,7 @@ func TestGuestAutoMatchLeavesNoStats(t *testing.T) { if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } - robotID, err := robots.Pick() + robotID, err := robots.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 80395a8..8908a38 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" + "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) @@ -25,7 +26,7 @@ type GameCreator interface { // auto-match. robot.Service satisfies it; it returns an error when no robot is // available so the matchmaker can defer substitution. type RobotProvider interface { - Pick() (uuid.UUID, error) + Pick(variant engine.Variant) (uuid.UUID, error) } // Blocker reports whether two accounts have a block between them (either diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 649e98f..48da0dd 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -197,12 +197,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { } var subs []sub for _, acc := range due { - robotID, err := m.robots.Pick() + variant := m.queued[acc] + robotID, err := m.robots.Pick(variant) if err != nil { m.log.Warn("robot substitution deferred", zap.Error(err)) continue } - variant := m.queued[acc] m.removeLocked(acc, variant) seats := []uuid.UUID{acc, robotID} if m.rng.Intn(2) == 0 { diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 6d59772..e2d145c 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -28,13 +28,15 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, } // fakeRobots is a RobotProvider returning a fixed robot id, or an error to model -// an empty pool. +// an empty pool. It records the variant of the last substitution request. type fakeRobots struct { - id uuid.UUID - err error + id uuid.UUID + err error + lastVariant engine.Variant } -func (f *fakeRobots) Pick() (uuid.UUID, error) { +func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) { + f.lastVariant = variant if f.err != nil { return uuid.Nil, f.err } diff --git a/backend/internal/robot/names.go b/backend/internal/robot/names.go new file mode 100644 index 0000000..a49d2fe --- /dev/null +++ b/backend/internal/robot/names.go @@ -0,0 +1,146 @@ +package robot + +// Robot display names are composed, not hand-listed. Per language there is a pool of +// 32 full first names and a paired pool of 32 colloquial forms (William/Bill, +// Анастасия/Настя), a surname pool, and three rendering forms: first name only; +// first name plus a surname initial; first name plus full surname. Because robots are +// durable accounts whose name must stay stable across restarts (a player's opponent +// must not rename itself on every deploy, nor mid-game), the composition is +// deterministic per pool slot — seeded by the slot index through mix — rather than +// re-randomised each boot. Russian surnames are gender-agreed with the first name. + +// robotPoolSize is the number of robot accounts provisioned per language. It equals +// the first-name pool size, so each slot draws a distinct person. +const robotPoolSize = 32 + +// latinShareInRussian is the approximate percentage of Russian-variant games that +// draw a Latin-named robot rather than a Russian-named one (the owner's "≤20%"). +const latinShareInRussian = 20 + +// name composition forms. +const ( + nameFormFirstOnly = iota // "Anna" + nameFormInitial // "Anna C." + nameFormFull // "Anna Carter" +) + +// genderedName is a Russian first name tagged by grammatical gender so the surname +// form (masculine vs feminine) can agree with it. +type genderedName struct { + name string + female bool +} + +// surnamePair holds a Russian surname's masculine and feminine forms. +type surnamePair struct{ m, f string } + +// firstNamesFullEN and firstNamesShortEN are paired by index: the same person's +// official and colloquial English first name (William/Bill). +var firstNamesFullEN = []string{ + "William", "Robert", "Thomas", "Nicholas", "Joseph", "Edward", "Charles", "Margaret", + "Elizabeth", "Katherine", "Alexander", "Samuel", "Andrew", "Christopher", "Benjamin", "Daniel", + "Matthew", "Anthony", "Michael", "Richard", "Jonathan", "Patricia", "Jennifer", "Jessica", + "Stephanie", "Victoria", "Theodore", "Frederick", "Gabriel", "Vincent", "Eleanor", "Josephine", +} + +var firstNamesShortEN = []string{ + "Will", "Bob", "Tom", "Nick", "Joe", "Eddie", "Charlie", "Maggie", + "Liz", "Kate", "Alex", "Sam", "Drew", "Chris", "Ben", "Dan", + "Matt", "Tony", "Mike", "Rick", "Jon", "Pat", "Jen", "Jess", + "Steph", "Vicky", "Ted", "Fred", "Gabe", "Vince", "Ellie", "Josie", +} + +// surnamesEN is a pool of gender-neutral English surnames. +var surnamesEN = []string{ + "Carter", "Hayes", "Brooks", "Reed", "Cole", "Lane", "Bishop", "Hart", + "Shaw", "Wells", "Pierce", "Wade", "Frost", "Doyle", "Boyd", "Marsh", + "Hale", "Nash", "Webb", "Dale", "Park", "Lyon", "Snow", "Cross", + "Vance", "Knox", "Page", "Ford", "Banks", "Foster", "Greer", "Mills", +} + +// firstNamesFullRU and firstNamesShortRU are paired by index: the same person's +// official and colloquial Russian first name (Анастасия/Настя), gender-tagged. +var firstNamesFullRU = []genderedName{ + {"Александр", false}, {"Дмитрий", false}, {"Сергей", false}, {"Алексей", false}, + {"Михаил", false}, {"Николай", false}, {"Владимир", false}, {"Константин", false}, + {"Павел", false}, {"Григорий", false}, {"Андрей", false}, {"Иван", false}, + {"Пётр", false}, {"Роман", false}, {"Антон", false}, {"Евгений", false}, + {"Анна", true}, {"Мария", true}, {"Анастасия", true}, {"Наталья", true}, + {"Татьяна", true}, {"Екатерина", true}, {"Юлия", true}, {"Ольга", true}, + {"Елена", true}, {"Ирина", true}, {"Светлана", true}, {"Полина", true}, + {"Дарья", true}, {"София", true}, {"Алина", true}, {"Вера", true}, +} + +var firstNamesShortRU = []genderedName{ + {"Саша", false}, {"Дима", false}, {"Серёжа", false}, {"Лёша", false}, + {"Миша", false}, {"Коля", false}, {"Володя", false}, {"Костя", false}, + {"Паша", false}, {"Гриша", false}, {"Андрюша", false}, {"Ваня", false}, + {"Петя", false}, {"Рома", false}, {"Антоша", false}, {"Женя", false}, + {"Аня", true}, {"Маша", true}, {"Настя", true}, {"Наташа", true}, + {"Таня", true}, {"Катя", true}, {"Юля", true}, {"Оля", true}, + {"Лена", true}, {"Ира", true}, {"Света", true}, {"Поля", true}, + {"Даша", true}, {"Соня", true}, {"Аля", true}, {"Верочка", true}, +} + +// surnamesRU is a pool of common Russian surnames in masculine and feminine forms. +var surnamesRU = []surnamePair{ + {"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"}, + {"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"}, + {"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"}, + {"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"}, + {"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"}, + {"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"}, + {"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"}, + {"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"}, + {"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"}, + {"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"}, + {"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"}, +} + +// robotDisplayNamesEN builds the English robot display-name pool, one per slot. Each +// slot draws its paired full or colloquial first name, a surname, and a form. +func robotDisplayNamesEN() []string { + out := make([]string, robotPoolSize) + for i := range out { + h := mix(int64(i), "robot-en") + first := firstNamesFullEN[i%len(firstNamesFullEN)] + if (h>>16)&1 == 1 { + first = firstNamesShortEN[i%len(firstNamesShortEN)] + } + surname := surnamesEN[h%uint64(len(surnamesEN))] + out[i] = composeName(first, surname, int((h>>8)%3)) + } + return out +} + +// robotDisplayNamesRU builds the Russian robot display-name pool, one per slot, with +// the surname form agreeing with the first name's gender. +func robotDisplayNamesRU() []string { + out := make([]string, robotPoolSize) + for i := range out { + h := mix(int64(i), "robot-ru") + fn := firstNamesFullRU[i%len(firstNamesFullRU)] + if (h>>16)&1 == 1 { + fn = firstNamesShortRU[i%len(firstNamesShortRU)] + } + sp := surnamesRU[h%uint64(len(surnamesRU))] + surname := sp.m + if fn.female { + surname = sp.f + } + out[i] = composeName(fn.name, surname, int((h>>8)%3)) + } + return out +} + +// composeName renders one of the three name forms from a first name and a surname. +func composeName(first, surname string, form int) string { + switch form { + case nameFormInitial: + return first + " " + string([]rune(surname)[:1]) + "." + case nameFormFull: + return first + " " + surname + default: + return first + } +} diff --git a/backend/internal/robot/names_test.go b/backend/internal/robot/names_test.go new file mode 100644 index 0000000..7925b8c --- /dev/null +++ b/backend/internal/robot/names_test.go @@ -0,0 +1,119 @@ +package robot + +import ( + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" +) + +// TestComposeName covers the three rendering forms, including a Cyrillic initial. +func TestComposeName(t *testing.T) { + cases := []struct { + first, surname string + form int + want string + }{ + {"Anna", "Carter", nameFormFirstOnly, "Anna"}, + {"Anna", "Carter", nameFormInitial, "Anna C."}, + {"Anna", "Carter", nameFormFull, "Anna Carter"}, + {"Маша", "Суханова", nameFormInitial, "Маша С."}, + {"Маша", "Суханова", nameFormFull, "Маша Суханова"}, + } + for _, c := range cases { + if got := composeName(c.first, c.surname, c.form); got != c.want { + t.Errorf("composeName(%q,%q,%d) = %q, want %q", c.first, c.surname, c.form, got, c.want) + } + } +} + +// TestNamePoolsPaired checks the full and colloquial first-name pools line up by +// index (so a slot's gender and person are consistent) and the surname forms differ. +func TestNamePoolsPaired(t *testing.T) { + if len(firstNamesFullEN) != robotPoolSize || len(firstNamesShortEN) != robotPoolSize { + t.Fatalf("EN first-name pools must each hold %d names", robotPoolSize) + } + if len(firstNamesFullRU) != robotPoolSize || len(firstNamesShortRU) != robotPoolSize { + t.Fatalf("RU first-name pools must each hold %d names", robotPoolSize) + } + for i := range firstNamesFullRU { + if firstNamesFullRU[i].female != firstNamesShortRU[i].female { + t.Errorf("RU pair %d disagrees on gender: %q vs %q", i, firstNamesFullRU[i].name, firstNamesShortRU[i].name) + } + } + for _, sp := range surnamesRU { + if sp.m == sp.f { + t.Errorf("RU surname forms should differ: %q", sp.m) + } + } +} + +// TestRobotDisplayNames checks the generated pools are the right size, non-empty and +// deterministic — durable robot accounts must keep a stable name across restarts. +func TestRobotDisplayNames(t *testing.T) { + en1, en2 := robotDisplayNamesEN(), robotDisplayNamesEN() + ru1, ru2 := robotDisplayNamesRU(), robotDisplayNamesRU() + if len(en1) != robotPoolSize || len(ru1) != robotPoolSize { + t.Fatalf("pool sizes en=%d ru=%d, want %d", len(en1), len(ru1), robotPoolSize) + } + for i := range en1 { + if en1[i] != en2[i] || ru1[i] != ru2[i] { + t.Fatalf("pool not deterministic at %d: en %q/%q ru %q/%q", i, en1[i], en2[i], ru1[i], ru2[i]) + } + if en1[i] == "" || ru1[i] == "" { + t.Fatalf("empty composed name at index %d", i) + } + } +} + +// TestPickVariantRouting checks English games draw the Latin pool and Russian games +// draw mostly Russian names with a Latin minority. +func TestPickVariantRouting(t *testing.T) { + enID, ruID := uuid.New(), uuid.New() + s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}} + for i := 0; i < 200; i++ { + if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID { + t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID) + } + } + var en, ru int + for i := 0; i < 4000; i++ { + got, err := s.Pick(engine.VariantRussianScrabble) + if err != nil { + t.Fatalf("russian Pick: %v", err) + } + switch got { + case enID: + en++ + case ruID: + ru++ + } + } + if ru <= en { + t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en) + } + if en == 0 { + t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)") + } + // Эрудит routes like Russian Scrabble. + if _, err := s.Pick(engine.VariantErudit); err != nil { + t.Errorf("erudit Pick: %v", err) + } +} + +// TestPickFallback checks an empty side falls back to the other pool and an empty pool +// errors. +func TestPickFallback(t *testing.T) { + id := uuid.New() + if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id { + t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id) + } + if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id { + t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id) + } + if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) { + t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err) + } +} diff --git a/backend/internal/robot/robot.go b/backend/internal/robot/robot.go index f4ef5cc..e6aa40d 100644 --- a/backend/internal/robot/robot.go +++ b/backend/internal/robot/robot.go @@ -55,13 +55,6 @@ type Nudger interface { LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) } -// robotNames is the curated, human-like name pool. Each name backs one durable -// robot account, addressed by a stable robot identity (its lower-cased name). -var robotNames = []string{ - "Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan", - "Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage", -} - // Config configures the robot subsystem. type Config struct { // DriveInterval is how often the driver scans for robot turns. Sourced from @@ -91,8 +84,9 @@ type Service struct { clock func() time.Time log *zap.Logger - mu sync.RWMutex - pool []uuid.UUID + mu sync.RWMutex + poolEN []uuid.UUID + poolRU []uuid.UUID } // NewService constructs a robot Service. games and social are the domain seams it @@ -120,58 +114,73 @@ func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter met } } -// EnsurePool idempotently provisions the named robot accounts and records their -// ids as the pool. Each robot is a durable account bound to a robot identity, -// with chat and friend requests blocked so it never engages socially -// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary -// registry: a failure fails the boot. +// EnsurePool idempotently provisions the robot accounts (one per slot of each +// language's composed name pool) and records their ids. Each robot is a durable +// account bound to a stable, index-keyed robot identity, with chat and friend +// requests blocked so it never engages socially (docs/ARCHITECTURE.md §7). It is a +// startup dependency, like the dictionary registry: a failure fails the boot. func (s *Service) EnsurePool(ctx context.Context) error { - ids := make([]uuid.UUID, 0, len(robotNames)) - for _, name := range robotNames { - acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name)) - if err != nil { - return fmt.Errorf("robot: provision %q: %w", name, err) - } - if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests { - if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{ - DisplayName: name, - PreferredLanguage: acc.PreferredLanguage, - TimeZone: acc.TimeZone, - AwayStart: acc.AwayStart, - AwayEnd: acc.AwayEnd, - BlockChat: true, - BlockFriendRequests: true, - }); err != nil { - return fmt.Errorf("robot: profile %q: %w", name, err) - } - } - ids = append(ids, acc.ID) + en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN()) + if err != nil { + return err + } + ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU()) + if err != nil { + return err } s.mu.Lock() - s.pool = ids + s.poolEN, s.poolRU = en, ru s.mu.Unlock() return nil } -// Pick returns a random robot account from the pool, for the matchmaker to -// substitute into an auto-match. It satisfies lobby.RobotProvider. -func (s *Service) Pick() (uuid.UUID, error) { - s.mu.RLock() - defer s.mu.RUnlock() - if len(s.pool) == 0 { - return uuid.Nil, ErrNoRobotAvailable +// provisionPool provisions one durable robot account per name and returns their ids +// in order. The identity is keyed by language and slot index (stable across restarts +// and independent of the composed display name); account.ProvisionRobot sets the +// display name and social blocks and is idempotent, so EnsurePool can run every boot. +func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) { + ids := make([]uuid.UUID, 0, len(names)) + for i, name := range names { + acc, err := s.accounts.ProvisionRobot(ctx, fmt.Sprintf("robot-%s-%d", lang, i), name) + if err != nil { + return nil, fmt.Errorf("robot: provision %s #%d (%q): %w", lang, i, name, err) + } + ids = append(ids, acc.ID) } - return s.pool[rand.IntN(len(s.pool))], nil + return ids, nil } -// poolIDs returns a snapshot of the pool for the driver scan. +// Pick returns a random robot account for the matchmaker to substitute into an +// auto-match of the given variant. An English game draws from the Latin pool; a +// Russian game (Russian Scrabble or Эрудит) draws from the Russian pool, mixing in a +// Latin name about latinShareInRussian% of the time; either side falls back to the +// other when its pool is empty. It satisfies lobby.RobotProvider. +func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) { + s.mu.RLock() + defer s.mu.RUnlock() + primary, secondary := s.poolEN, s.poolRU + if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit { + primary, secondary = s.poolRU, s.poolEN + if len(primary) > 0 && len(secondary) > 0 && rand.IntN(100) < latinShareInRussian { + primary, secondary = secondary, primary + } + } + if len(primary) == 0 { + primary = secondary + } + if len(primary) == 0 { + return uuid.Nil, ErrNoRobotAvailable + } + return primary[rand.IntN(len(primary))], nil +} + +// poolIDs returns a snapshot of the whole pool (both languages) for the driver scan, +// which is variant-agnostic — it acts on every robot's active games. func (s *Service) poolIDs() []uuid.UUID { s.mu.RLock() defer s.mu.RUnlock() - return append([]uuid.UUID(nil), s.pool...) -} - -// externalID is the stable robot identity for a pool name. -func externalID(name string) string { - return "robot-" + name + ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU)) + ids = append(ids, s.poolEN...) + ids = append(ids, s.poolRU...) + return ids } diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index 7301670..633dd98 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -23,17 +23,27 @@ const ( // human wins about 60% of games (docs/ARCHITECTURE.md §7). playToWinPercent = 40 - // delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the - // right-skewed distribution (short delays frequent). With skew 3.5 the median - // is about 10 minutes and the mean about 20, with a tail out to the maximum. - delayMinMinutes = 2.0 - delayMaxMinutes = 90.0 - delaySkew = 3.5 + // The robot's think time depends on how far the game has progressed: early moves + // are quick and late moves can be long (endgame deliberation). The delay is drawn + // from a band that interpolates with the move count from [delayEarlyLoMinutes, + // delayEarlyHiMinutes] at the first move to [delayLateLoMinutes, delayLateHiMinutes] + // by avgGameMoves, then right-skewed by delaySkew (a larger exponent concentrates + // delays near the band's floor — an active player). The result is clamped to + // [delayHardMinMinutes, delayHardMaxMinutes]. The numbers are deliberate estimates, + // to be retuned once real play statistics arrive (docs/ARCHITECTURE.md §7). + delayEarlyLoMinutes = 1.0 + delayEarlyHiMinutes = 5.0 + delayLateLoMinutes = 10.0 + delayLateHiMinutes = 90.0 + delaySkew = 4.0 + avgGameMoves = 28.0 + delayHardMinMinutes = 1.0 + delayHardMaxMinutes = 90.0 - // nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot - // answers a daytime nudge on its turn. - nudgeReplyMinMinutes = 2.0 - nudgeReplyMaxMinutes = 10.0 + // nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's + // lower band (delayBand's lo), within which the robot answers a daytime nudge on + // its turn — so a nudged robot replies near the floor of its think time. + nudgeReplySpreadMinutes = 5.0 // sleepStartHour and sleepEndHour bound the robot's nightly sleep in its // (opponent-anchored, drifted) local time: it makes no move and sends no nudge @@ -104,19 +114,48 @@ func playToWin(seed int64) bool { return mix(seed, "win")%100 < playToWinPercent } -// moveDelay is the robot's think time for the move at moveCount, sampled from the -// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes). +// delayBand returns the lower and upper bounds, in minutes, of the move-delay band +// for the move at moveCount. It interpolates linearly with game progress (the move +// count over avgGameMoves, capped at 1): early moves sit in a short band and late +// moves in a long one. +func delayBand(moveCount int) (lo, hi float64) { + p := float64(moveCount) / avgGameMoves + if p > 1 { + p = 1 + } + lo = delayEarlyLoMinutes + (delayLateLoMinutes-delayEarlyLoMinutes)*p + hi = delayEarlyHiMinutes + (delayLateHiMinutes-delayEarlyHiMinutes)*p + return lo, hi +} + +// moveDelay is the robot's think time for the move at moveCount: a right-skewed +// sample from the move's delayBand, clamped to the hard bounds. The skew (delaySkew +// > 1) makes short delays frequent and long ones rare, with a tail to the band's top. func moveDelay(seed int64, moveCount int) time.Duration { + lo, hi := delayBand(moveCount) u := unitFloat(mix(seed, "delay", moveCount)) - mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew) - return time.Duration(mins * float64(time.Minute)) + return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew)) } // nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at -// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes). +// moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes], +// where lo is the move's lower band — so a nudge pulls the move in near the floor of +// the robot's think time. func nudgeReplyDelay(seed int64, moveCount int) time.Duration { + lo, _ := delayBand(moveCount) u := unitFloat(mix(seed, "nudge", moveCount)) - mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u + return clampMinutes(lo + nudgeReplySpreadMinutes*u) +} + +// clampMinutes converts a minute count to a duration, clamping it to the hard delay +// bounds so an out-of-range band can never produce an absurd think time. +func clampMinutes(mins float64) time.Duration { + if mins < delayHardMinMinutes { + mins = delayHardMinMinutes + } + if mins > delayHardMaxMinutes { + mins = delayHardMaxMinutes + } return time.Duration(mins * float64(time.Minute)) } diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go index 3161e4b..e230ea6 100644 --- a/backend/internal/robot/strategy_test.go +++ b/backend/internal/robot/strategy_test.go @@ -27,14 +27,14 @@ func TestPlayToWinDistribution(t *testing.T) { } } -// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in -// [2min, 90min) and is reproducible for a (seed, moveCount). +// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard +// bounds [1min, 90min] and is reproducible for a (seed, moveCount). func TestMoveDelayBoundsAndDeterminism(t *testing.T) { for seed := int64(1); seed <= 200; seed++ { for mc := 0; mc < 50; mc++ { d := moveDelay(seed, mc) - if d < 2*time.Minute || d >= 90*time.Minute { - t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc) + if d < 1*time.Minute || d > 90*time.Minute { + t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc) } if moveDelay(seed, mc) != d { t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc) @@ -43,22 +43,49 @@ func TestMoveDelayBoundsAndDeterminism(t *testing.T) { } } -// TestMoveDelaySkew checks the distribution is right-skewed with the intended -// ~10-minute median: most delays are short, the mean sits above the median. +// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the +// first move lives in the short [1,5]min band, a late move in the long [10,90]min +// band, so the median think time rises with the move count. +func TestMoveDelayGrowsWithMoveCount(t *testing.T) { + median := func(mc int) float64 { + const n = 4000 + xs := make([]float64, n) + for s := 0; s < n; s++ { + xs[s] = moveDelay(int64(s+1), mc).Minutes() + } + sort.Float64s(xs) + return xs[n/2] + } + for s := int64(1); s <= 500; s++ { + if d := moveDelay(s, 0).Minutes(); d < 1 || d > 5 { + t.Fatalf("first-move delay %.2f out of [1,5] for seed %d", d, s) + } + if d := moveDelay(s, 40).Minutes(); d < 10 || d > 90 { + t.Fatalf("late-move delay %.2f out of [10,90] for seed %d", d, s) + } + } + if early, late := median(0), median(30); early >= late { + t.Errorf("median should grow with move count: move0=%.1f move30=%.1f", early, late) + } +} + +// TestMoveDelaySkew checks the late-game distribution is right-skewed at a fixed move +// count: short delays are frequent (median near the band floor) and the mean sits +// above the median, with a tail toward the cap. func TestMoveDelaySkew(t *testing.T) { const n = 20000 mins := make([]float64, 0, n) var sum float64 - for mc := 0; mc < n; mc++ { - m := moveDelay(42, mc).Minutes() + for s := 0; s < n; s++ { + m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90] mins = append(mins, m) sum += m } sort.Float64s(mins) median := mins[n/2] mean := sum / float64(n) - if median < 7 || median > 13 { - t.Errorf("median delay = %.1f min, want ~10 (7-13)", median) + if median < 12 || median > 20 { + t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median) } if mean <= median { t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median) diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index 7f0ae7a..517c8fc 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -46,6 +46,7 @@ func TestStatusForError(t *testing.T) { }{ "not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"}, "not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"}, + "nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"}, "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index dbbe120..f4e64f9 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -148,8 +148,10 @@ func statusForError(err error) (int, string) { return http.StatusNotFound, "not_found" case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant): return http.StatusForbidden, "not_a_player" - case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn): + case errors.Is(err, game.ErrNotYourTurn): return http.StatusConflict, "not_your_turn" + case errors.Is(err, social.ErrNudgeOnOwnTurn): + return http.StatusConflict, "nudge_own_turn" case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive): return http.StatusConflict, "game_finished" case errors.Is(err, game.ErrGameActive): diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index d8e2265..5fdf3aa 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -82,6 +82,7 @@ func (s *Server) consoleUsers(c *gin.Context) { return } view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)} + ids := make([]uuid.UUID, 0, len(accs)) for _, a := range accs { kind := "registered" if a.IsGuest { @@ -91,6 +92,17 @@ func (s *Server) consoleUsers(c *gin.Context) { ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind, Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt), }) + ids = append(ids, a.ID) + } + if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil { + for i := range view.Items { + if st, ok := stats[ids[i]]; ok && st.Moves > 0 { + view.Items[i].HasMoveStats = true + view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs) + view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs) + view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs) + } + } } s.renderConsole(c, "users", "users", "Users", view) } @@ -134,6 +146,13 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view.Games = append(view.Games, gameRow(g)) } } + if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 { + cps := make([]adminconsole.ChartPoint, len(pts)) + for i, p := range pts { + cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs} + } + view.MoveChart = adminconsole.MoveDurationChart(cps) + } s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 6cf7c09..99b53b5 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -148,6 +148,7 @@ export const en = { 'lang.ru': 'Русский', 'error.not_your_turn': "It is not your turn.", + 'error.nudge_own_turn': 'It is your turn — there is no one to nudge.', 'error.illegal_play': 'That is not a legal play.', 'error.hint_unavailable': 'No hints available.', 'error.no_hint_available': 'No options with your letters.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 7034bd2..1ffcbf3 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -149,6 +149,7 @@ export const ru: Record = { 'lang.ru': 'Русский', 'error.not_your_turn': 'Сейчас не ваш ход.', + 'error.nudge_own_turn': 'Сейчас ваш ход — некого торопить.', 'error.illegal_play': 'Это недопустимый ход.', 'error.hint_unavailable': 'Подсказки недоступны.', 'error.no_hint_available': 'Нет вариантов с вашим набором.', From c0b46a7ca6a012b3f5393bd76091790e0aba83c3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 6 Jun 2026 10:05:01 +0200 Subject: [PATCH 02/28] Stage 17: path-conditional CI behind an aggregate gate + connector liveness probe; Grafana move-duration panel - #10 a `changes` job path-filters unit/integration/ui; an always-running `gate` job aggregates them (success-or-skipped) and becomes the only required check - #9 deploy adds a Telegram-connector liveness probe (docker inspect: running, not restarting, stable restart count) with a VPN-handshake grace period - #1a Game-domain dashboard gains a 'Move think-time by phase (p50/p95)' panel - deploy README: branch protection now requires only CI / gate --- .gitea/workflows/ci.yaml | 119 ++++++++++++++++++++- deploy/README.md | 7 +- deploy/grafana/dashboards/game-domain.json | 14 ++- 3 files changed, 135 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index bfb321b..67d46a9 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,6 +1,6 @@ name: CI -# Single gated pipeline for the test contour (Stage 16). Gitea cannot express +# Single gated pipeline for the test contour (Stage 16/17). Gitea cannot express # cross-workflow `needs`, so the full test suite and the auto test-deploy live in # one workflow. # @@ -11,6 +11,12 @@ name: CI # (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual # workflow (Stage 18). # +# Path-conditional jobs (Stage 17): `unit`/`integration`/`ui` run only when their +# code changed (the `changes` job decides). Because a skipped required check would +# block a merge under branch protection, the always-running `gate` job aggregates +# their results and is the ONLY required status check; it passes when every +# upstream job either succeeded or was skipped. +# # Console output is kept plain (NO_COLOR + `docker compose --ansi never` + # `--progress plain`) so the Gitea logs stay readable. @@ -21,7 +27,57 @@ on: branches: [development] jobs: + # changes detects which areas a PR/push touched, so the test jobs can skip when + # irrelevant. It defaults to running everything when the diff cannot be computed. + changes: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + outputs: + go: ${{ steps.filter.outputs.go }} + ui: ${{ steps.filter.outputs.ui }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed paths + id: filter + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch -q origin "${{ github.base_ref }}" || true + range="origin/${{ github.base_ref }}...HEAD" + else + before="${{ github.event.before }}" + if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "${before}^{commit}" 2>/dev/null; then + range="HEAD~1...HEAD" + else + range="${before}...HEAD" + fi + fi + echo "comparison range: $range" + # Default to running everything; narrow only when the diff is computable. + go=true; ui=true + files="$(git diff --name-only "$range" 2>/dev/null || echo __DIFF_FAILED__)" + if [ "$files" != "__DIFF_FAILED__" ]; then + echo "changed files:"; echo "$files" + go=false; ui=false + if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|go\.work)'; then go=true; fi + if echo "$files" | grep -qE '^ui/'; then ui=true; fi + # A workflow or deploy change re-runs everything as a safety net. + if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi + else + echo "diff failed; running all jobs" + fi + echo "selected: go=$go ui=$ui" + echo "go=$go" >> "$GITHUB_OUTPUT" + echo "ui=$ui" >> "$GITHUB_OUTPUT" + unit: + needs: changes + if: ${{ needs.changes.outputs.go == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -67,6 +123,8 @@ jobs: run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... integration: + needs: changes + if: ${{ needs.changes.outputs.go == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -102,6 +160,8 @@ jobs: run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/... ui: + needs: changes + if: ${{ needs.changes.outputs.ui == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -142,10 +202,37 @@ jobs: run: pnpm run test:e2e timeout-minutes: 5 + # gate is the single branch-protection required check. It always runs and passes + # only when each upstream job succeeded or was skipped (a path-filtered no-op), + # failing the merge if any actually failed or was cancelled. + gate: + needs: [unit, integration, ui] + if: always() + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Aggregate required checks + run: | + fail= + for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do + name="${r%%:*}"; res="${r#*:}" + echo "$name = $res" + case "$res" in + success|skipped) ;; + *) echo "::error::$name=$res"; fail=1 ;; + esac + done + [ -z "$fail" ] || { echo "one or more required jobs failed"; exit 1; } + echo "all required jobs passed or were skipped" + deploy: # Auto test-deploy on a PR into development and on the push that merges it. # A PR into master is test-only (this job is skipped); prod deploy is manual. - needs: [unit, integration, ui] + # Gates on `gate` (so a real test failure blocks the deploy) but runs even when + # some test jobs were path-skipped. + needs: [gate] if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }} runs-on: ubuntu-latest defaults: @@ -215,6 +302,34 @@ jobs: docker logs --tail 50 scrabble-gateway || true exit 1 + - name: Probe the Telegram connector liveness + run: | + set -u + # The gateway probe cannot see a crash-looping connector (it long-polls and + # egresses through the VPN sidecar, with no public ingress). Inspect the + # container directly: it must be running, not restarting, with a stable + # restart count. A grace period lets the VPN handshake settle (the connector + # may restart a few times first). + sleep 20 + for i in $(seq 1 20); do + status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)" + restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)" + if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then + c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)" + sleep 5 + c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)" + if [ "$c1" = "$c2" ]; then + echo "connector healthy: status=$status restarts=$c2" + exit 0 + fi + echo "connector still restarting ($c1 -> $c2); waiting" + fi + sleep 3 + done + echo "connector not healthy; recent logs:" + docker logs --tail 80 scrabble-telegram || true + exit 1 + - name: Prune dangling images if: always() run: docker image prune -f diff --git a/deploy/README.md b/deploy/README.md index 62ab89d..b5778f4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -110,5 +110,8 @@ resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentional - **Host caddy** route ` → scrabble:80` (the in-compose caddy serves HTTP in the test contour; the host caddy terminates TLS). Not needed on prod, where the contour caddy owns TLS (set `CADDY_SITE_ADDRESS` to the domain). -- **Branch protection** required-status-check names are `CI / unit`, - `CI / integration`, `CI / ui` (see [`../CLAUDE.md`](../CLAUDE.md) "Branching & CI"). +- **Branch protection** requires the single status check `CI / gate` (Stage 17). + The `unit` / `integration` / `ui` jobs are path-conditional (they skip when their + code did not change), and the always-running `gate` job aggregates them (passing + when each succeeded or was skipped), so a skipped job never blocks a merge. See + [`../CLAUDE.md`](../CLAUDE.md) "Branching & CI". diff --git a/deploy/grafana/dashboards/game-domain.json b/deploy/grafana/dashboards/game-domain.json index 90d76f9..53594c2 100644 --- a/deploy/grafana/dashboards/game-domain.json +++ b/deploy/grafana/dashboards/game-domain.json @@ -4,7 +4,7 @@ "tags": ["scrabble"], "timezone": "", "schemaVersion": 39, - "version": 1, + "version": 2, "refresh": "30s", "time": { "from": "now-24h", "to": "now" }, "panels": [ @@ -54,6 +54,18 @@ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "datasource": { "type": "prometheus", "uid": "prometheus" }, "targets": [{ "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(game_move_validate_duration_bucket[5m])) by (le, variant))", "legendFormat": "{{variant}}" }] + }, + { + "type": "timeseries", + "title": "Move think-time by phase (p50 / p95)", + "description": "Seconds a seat spent on a committed move, by game phase. Aggregates all seats including robots; per-human analysis is in the admin console.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { "refId": "A", "expr": "histogram_quantile(0.5, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p50 {{phase}}" }, + { "refId": "B", "expr": "histogram_quantile(0.95, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p95 {{phase}}" } + ] } ] } From 1d0bafaabb697ba18cdd2c056e727d0a360c456d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 6 Jun 2026 10:23:42 +0200 Subject: [PATCH 03/28] Stage 17: UI defect fixes (russian variant, Telegram theme/nav/banner, reconnect, hint zoom, plaque, history, transitions, per-game cache) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400 - #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher - #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token - #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground - #17 hint zoom scrolls to the placement's bounding box, not the top-left - #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history - #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board - #3 (UI) disable nudge on the player's own turn - #18a directional screen slide transitions (forward in from the right, back reveals the lobby) - #13 per-game in-memory cache: instant render on re-entry + background refresh - e2e: openGame waits for the slide transition to settle --- ui/e2e/game.spec.ts | 4 ++ ui/src/App.svelte | 73 ++++++++++++++++++++++++------- ui/src/app.css | 3 ++ ui/src/components/AdBanner.svelte | 2 +- ui/src/game/Chat.svelte | 6 ++- ui/src/game/Game.svelte | 61 ++++++++++++++++++++++---- ui/src/lib/app.svelte.ts | 53 +++++++++++++++++++--- ui/src/lib/gamecache.ts | 30 +++++++++++++ ui/src/lib/mock/alphabet.ts | 2 +- ui/src/lib/mock/client.ts | 2 +- ui/src/lib/mock/data.ts | 2 +- ui/src/lib/model.ts | 2 +- ui/src/lib/premiums.test.ts | 2 +- ui/src/lib/telegram.ts | 12 +++++ ui/src/lib/theme.ts | 5 +++ ui/src/lib/variants.test.ts | 4 +- ui/src/lib/variants.ts | 4 +- ui/src/screens/Lobby.svelte | 2 +- ui/src/screens/Settings.svelte | 23 +++++----- 19 files changed, 239 insertions(+), 53 deletions(-) create mode 100644 ui/src/lib/gamecache.ts diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index b38d17f..cce90f7 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -10,6 +10,10 @@ async function openGame(page: Page): Promise { await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann await expect(page.locator('[data-cell]').first()).toBeVisible(); + // Wait for the screen-slide transition to settle so only the game pane remains; + // until it does, the leaving lobby pane's header (its menu button) is also in the + // DOM, which would make shared locators like .burger ambiguous. + await expect(page.locator('.pane')).toHaveCount(1); } test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => { diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 1acb335..6247051 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,5 +1,6 @@ {#if !app.ready}
{t('common.loading')}
-{:else if router.route.name === 'login'} - -{:else if router.route.name === 'new'} - -{:else if router.route.name === 'game'} - -{:else if router.route.name === 'profile'} - -{:else if router.route.name === 'settings'} - -{:else if router.route.name === 'about'} - -{:else if router.route.name === 'friends'} - -{:else if router.route.name === 'stats'} - {:else} - +
+ {#key routeKey} +
+ {#if router.route.name === 'login'} + + {:else if router.route.name === 'new'} + + {:else if router.route.name === 'game'} + + {:else if router.route.name === 'profile'} + + {:else if router.route.name === 'settings'} + + {:else if router.route.name === 'about'} + + {:else if router.route.name === 'friends'} + + {:else if router.route.name === 'stats'} + + {:else} + + {/if} +
+ {/key} +
{/if} @@ -50,4 +80,13 @@ place-items: center; color: var(--text-muted); } + .router { + position: relative; + height: 100%; + overflow: hidden; + } + .pane { + position: absolute; + inset: 0; + } diff --git a/ui/src/app.css b/ui/src/app.css index 2bde4aa..ff1ed3e 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -11,6 +11,7 @@ --bg-elev: #ffffff; --surface: #ffffff; --surface-2: #eef0f3; + --ad-bg: #e3e7ee; /* announcement banner: a subtle accent, darker in light theme */ --text: #14181f; --text-muted: #6b7280; --border: #d8dce2; @@ -51,6 +52,7 @@ --bg-elev: #171a21; --surface: #171a21; --surface-2: #1f242d; + --ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */ --text: #e7eaf0; --text-muted: #9aa3b2; --border: #2a313c; @@ -82,6 +84,7 @@ --bg-elev: #171a21; --surface: #171a21; --surface-2: #1f242d; + --ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */ --text: #e7eaf0; --text-muted: #9aa3b2; --border: #2a313c; diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index 843d2b0..e5d2e04 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -57,7 +57,7 @@ overflow: hidden; white-space: nowrap; padding: 6px 0; - background: var(--surface-2); + background: var(--ad-bg); color: var(--text-muted); font-size: 0.85rem; line-height: 1.2; diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 638f6b5..1cad5c0 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -6,12 +6,16 @@ messages, myId, busy, + canNudge = true, onsend, onnudge, }: { messages: ChatMessage[]; myId: string; busy: boolean; + // Nudging only makes sense while waiting on the opponent; it is disabled on the + // player's own turn (there is no one to hurry along). + canNudge?: boolean; onsend: (text: string) => void; onnudge: () => void; } = $props(); @@ -47,7 +51,7 @@ onkeydown={(e) => e.key === 'Enter' && send()} /> - + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2aebef7..dd82a80 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -18,6 +18,7 @@ import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { canCheckWord, sanitizeCheckWord } from '../lib/checkword'; import { shareOrDownloadGcg } from '../lib/share'; + import { getCachedGame, setCachedGame } from '../lib/gamecache'; import { BLANK, newPlacement, @@ -94,6 +95,7 @@ ]); view = st; moves = hist.moves; + setCachedGame(id, st, hist.moves); placement = newPlacement(st.rack); preview = null; selected = null; @@ -109,7 +111,17 @@ handleError(e); } } - onMount(load); + onMount(() => { + // Render instantly from the cache (a game opened before), then refresh in the + // background. A cold open shows the loading state until load() resolves. + const cached = getCachedGame(id); + if (cached) { + view = cached.view; + moves = cached.moves; + placement = newPlacement(cached.view.rack); + } + void load(); + }); $effect(() => { const e = app.lastEvent; @@ -269,6 +281,17 @@ const h = await gateway.hint(id); if (h.move.tiles.length && view) { placement = placementFromHint(h.move.tiles, view.rack); + // Scroll the (zoomed) board to the hint's placement rather than the top-left: + // focus the centre of the laid tiles' bounding box. + const p = placement.pending; + if (p.length) { + const rows = p.map((tt) => tt.row); + const cols = p.map((tt) => tt.col); + focus = { + row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2), + col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2), + }; + } if (isCoarse()) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining }; recompute(); @@ -428,7 +451,9 @@ {/snippet} {#if view} -
+ + +
(historyOpen = !historyOpen)}> {#each view.game.seats as s (s.seat)}
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
@@ -599,26 +624,39 @@ {#if panel === 'chat'} (panel = 'none')}> - + {/if} diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index dd82a80..bc258cc 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -126,7 +126,11 @@ $effect(() => { const e = app.lastEvent; if (!e) return; - if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load(); + if (e.kind === 'opponent_moved' && e.gameId === id) { + // Skip the echo of my own move (the backend now notifies the actor too, for the + // player's other devices): this device already reloaded after the submit. + if (e.seat !== view?.seat) void load(); + } else if (e.kind === 'your_turn' && e.gameId === id) void load(); else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat(); }); @@ -522,13 +526,7 @@
{#if !gameOver && placement.pending.length > 0} - - {#snippet trigger()}🏁{/snippet} - {#snippet popover(close)} - - - {/snippet} - + {/if}
{:else} @@ -545,16 +543,22 @@ {#snippet trigger()}🥺{t('game.skip')}{/snippet} {#snippet popover(close)}{/snippet} - + {#snippet trigger()} 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} {t('game.hint')} {/snippet} {#snippet popover(close)}{/snippet} - + {#if placement.pending.length > 0} + + {:else} + + {/if} {/if} {/snippet} @@ -641,15 +645,18 @@ text-align: center; padding: 5px 4px; border-radius: var(--radius-sm); - background: var(--surface-2); - /* inactive seats read as "sunk in" */ - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.22); + /* inactive seats recede: they blend into the bar, slightly sunk */ + background: transparent; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18); + } + .seat .nm { + color: var(--text-muted); } .seat.turn { - /* the active seat is "raised": lifted clear of the others with side shadows */ - background: var(--bg-elev); + /* the active seat pops: a raised, accented chip lifted clear of the bar */ + background: var(--surface-2); box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.16), + 0 2px 6px rgba(0, 0, 0, 0.3), -3px 0 6px -2px rgba(0, 0, 0, 0.26), 3px 0 6px -2px rgba(0, 0, 0, 0.26); position: relative; @@ -767,16 +774,18 @@ flex: 1; min-width: 0; } - .flag { - font-size: 1.6rem; - } - :global(.make) { + .make { min-width: 56px; background: var(--accent); color: var(--accent-text); + border: none; border-radius: var(--radius-sm); display: grid; place-items: center; + font-size: 1.6rem; + } + .make:disabled { + opacity: 0.55; } .pop { padding: 9px 14px; diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 926b921..848bbbd 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -9,7 +9,7 @@ import { GatewayError } from './client'; import { navigate, router } from './router.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; -import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch } from './telegram'; +import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, telegramOnEvent } from './telegram'; import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearGameCache } from './gamecache'; @@ -52,11 +52,38 @@ let streamAlive = false; let reconnectTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; -/** documentHidden reports whether the app is currently backgrounded. */ +// Background/foreground tracking, to silence the reconnect banner during a normal app +// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return. +let backgrounded = false; +let foregroundedAt = 0; +const reconnectGraceMs = 4000; + +/** documentHidden reports whether the page is currently hidden. */ function documentHidden(): boolean { return typeof document !== 'undefined' && document.visibilityState === 'hidden'; } +/** + * bannerSuppressed reports whether the connection banner should stay hidden: while + * backgrounded, and for a short grace after returning to the foreground — a connection + * dropped while suspended surfaces its error on resume, before the silent reconnect lands. + */ +function bannerSuppressed(): boolean { + return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs; +} + +function goBackground(): void { + backgrounded = true; +} + +function goForeground(): void { + backgrounded = false; + foregroundedAt = Date.now(); + if (!app.session) return; + if (!streamAlive) openStream(); // silently re-establish a stream dropped while away + void refreshNotifications(); +} + export function showToast(text: string, kind: Toast['kind'] = 'info'): void { app.toast = { kind, text }; if (toastTimer) clearTimeout(toastTimer); @@ -96,14 +123,10 @@ function openStream(): void { }, () => { streamAlive = false; - // A background suspend (iOS / Telegram) drops the single-shot stream. Don't - // alarm the user with the connection banner while hidden — reconnect silently - // on return (the visibilitychange handler). Show the banner only on a failure - // seen in the foreground, and retry it. - if (!documentHidden()) { - showToast(t('error.unavailable'), 'error'); - scheduleReconnect(); - } + // A background suspend drops the single-shot stream. Keep the banner hidden while + // backgrounded or just-resumed (bannerSuppressed); always schedule a retry. + if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error'); + scheduleReconnect(); }, ); } @@ -114,7 +137,7 @@ function scheduleReconnect(): void { if (reconnectTimer || !app.session) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; - if (app.session && !streamAlive && !documentHidden()) openStream(); + if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream(); }, 4000); } @@ -353,14 +376,18 @@ export function setBoardLabels(mode: BoardLabelMode): void { persistPrefs(); } -// On return to the foreground: silently re-establish a stream dropped while the app -// was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any -// push 'notify' missed while hidden (poll + push, see §10). +// Background/foreground lifecycle: silence the reconnect banner during a suspend and +// reconnect quietly on return (and refresh the lobby badge for any push missed while +// hidden, §10). Several signals cover the platforms: the page Visibility API, the +// pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0). if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && app.session) { - if (!streamAlive) openStream(); - void refreshNotifications(); - } - }); + document.addEventListener('visibilitychange', () => + document.visibilityState === 'visible' ? goForeground() : goBackground(), + ); } +if (typeof window !== 'undefined') { + window.addEventListener('pageshow', goForeground); + window.addEventListener('pagehide', goBackground); +} +telegramOnEvent('activated', goForeground); +telegramOnEvent('deactivated', goBackground); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index da5455d..5440086 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -12,6 +12,7 @@ interface TelegramWebApp { colorScheme?: 'light' | 'dark'; ready?: () => void; expand?: () => void; + onEvent?: (event: string, handler: () => void) => void; } function webApp(): TelegramWebApp | undefined { @@ -49,6 +50,15 @@ export function telegramLaunch(): TelegramLaunch { return { initData: w.initData, startParam, theme: w.themeParams }; } +/** + * telegramOnEvent subscribes to a Telegram WebApp lifecycle event (e.g. 'activated' / + * 'deactivated', added in Bot API 8.0). It is a no-op outside Telegram or on a client + * that predates the event, so callers can register defensively. + */ +export function telegramOnEvent(event: string, handler: () => void): void { + webApp()?.onEvent?.(event, handler); +} + /** * telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'), * or undefined outside Telegram. Inside the Mini App this — not the OS From 645a50353234fd4e8a8254724043ff0b8c5ec044 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 6 Jun 2026 12:38:04 +0200 Subject: [PATCH 06/28] =?UTF-8?q?Stage=2017=20(#4):=20in-memory=20lobby=20?= =?UTF-8?q?cache=20=E2=80=94=20render=20instantly=20on=20the=20back-slide,?= =?UTF-8?q?=20refresh=20in=20background?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/lib/app.svelte.ts | 2 ++ ui/src/lib/lobbycache.ts | 30 ++++++++++++++++++++++++++++++ ui/src/screens/Lobby.svelte | 14 +++++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 ui/src/lib/lobbycache.ts diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 848bbbd..e001ba9 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -13,6 +13,7 @@ import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, te import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearGameCache } from './gamecache'; +import { clearLobby } from './lobbycache'; import type { BoardLabelMode } from './boardlabels'; export interface Toast { @@ -311,6 +312,7 @@ export async function loginEmail(email: string, code: string): Promise { export async function logout(): Promise { closeStream(); clearGameCache(); + clearLobby(); gateway.setToken(null); await clearSession(); app.session = null; diff --git a/ui/src/lib/lobbycache.ts b/ui/src/lib/lobbycache.ts new file mode 100644 index 0000000..5184bf8 --- /dev/null +++ b/ui/src/lib/lobbycache.ts @@ -0,0 +1,30 @@ +// In-memory lobby snapshot, the lobby counterpart of gamecache.ts. The lobby re-fetches +// its lists on every entry, so without a cache the screen renders blank and "draws in" +// during the back-slide from a game. Caching the last lists lets the lobby render +// instantly (before/under the transition) and refresh in the background. Process-memory +// only; cleared on logout. + +import type { AccountRef, GameView, Invitation } from './model'; + +interface LobbySnapshot { + games: GameView[]; + invitations: Invitation[]; + incoming: AccountRef[]; +} + +let snapshot: LobbySnapshot | null = null; + +/** getLobby returns the last lobby lists, or null before the first load. */ +export function getLobby(): LobbySnapshot | null { + return snapshot; +} + +/** setLobby stores the latest lobby lists. */ +export function setLobby(s: LobbySnapshot): void { + snapshot = s; +} + +/** clearLobby drops the cached lobby (called on logout). */ +export function clearLobby(): void { + snapshot = null; +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 685a983..daf61a4 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -8,6 +8,7 @@ import { navigate } from '../lib/router.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { resultBadge } from '../lib/result'; + import { getLobby, setLobby } from '../lib/lobbycache'; import type { AccountRef, GameView, Invitation } from '../lib/model'; let games = $state([]); @@ -23,12 +24,23 @@ [invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); app.notifications = invitations.length + incoming.length; } + setLobby({ games, invitations, incoming }); } catch (e) { handleError(e); } } - onMount(load); + onMount(() => { + // Render instantly from the cached lists (so the screen does not "draw in" during + // the back-slide), then refresh in the background. + const cached = getLobby(); + if (cached) { + games = cached.games; + invitations = cached.invitations; + incoming = cached.incoming; + } + void load(); + }); $effect(() => { if (app.lastEvent) void load(); }); From f6bffd1f5736c8c2a76e51357d15f9dde719ccf8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 6 Jun 2026 12:55:46 +0200 Subject: [PATCH 07/28] Stage 17 (contour round 3): Telegram Mini Apps polish, board scroll, keyboard overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Telegram (lib/telegram.ts): chrome colours (setHeaderColor/setBackgroundColor/setBottomBarColor) match Telegram's header/bg/bottom bar to the app; native BackButton on sub-screens (app chevron hidden in TG); HapticFeedback on tile place/commit/error; enableClosingConfirmation while a game is open; disableVerticalSwipes so swipe-to-minimise doesn't fight tile drag / board scroll - #9 board-only vertical scroll: Screen 'column' mode lets the board area scroll while score/status/rack/tab bar stay fixed (zoom keeps its own scroll) - #10 check-word dialog opens in Modal keyboard-overlay mode (top-anchored, keyboard overlays the empty area) — no resize/relayout jank; other modals stay keyboard-aware - docs: UI_DESIGN Telegram integration + vertical fit/keyboard; PLAN round 2-3 follow-ups --- PLAN.md | 15 ++++++ docs/UI_DESIGN.md | 31 +++++++++---- ui/src/App.svelte | 12 ++++- ui/src/components/Header.svelte | 7 ++- ui/src/components/Modal.svelte | 14 +++++- ui/src/components/Screen.svelte | 10 +++- ui/src/game/Game.svelte | 21 +++++++-- ui/src/lib/app.svelte.ts | 30 +++++++++++- ui/src/lib/telegram.ts | 82 +++++++++++++++++++++++++++++++++ 9 files changed, 204 insertions(+), 18 deletions(-) diff --git a/PLAN.md b/PLAN.md index fd0b767..7ae8a4a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1254,6 +1254,21 @@ provided cert) at the contour caddy; prod VPN; rollback. and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify, no server change. **Multi-word history (#22)** was already implemented (all formed words shown). + - **Contour-verification follow-ups** (rounds 2–3, from live testing): the Grafana + double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is + disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the + move-duration panel was invisible because the deploy reseed (`rm -rf`) left the + config-only services on a stale bind mount — the deploy now **force-recreates** + caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 → + 300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram + reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide + + Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted — + chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native + **BackButton**, **HapticFeedback**, **closing confirmation** in a game, + **disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat + pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint + button disables at zero hints; plus **board-only vertical scroll** (#9) and a + **keyboard-overlay** check-word dialog (#10). ## Deferred TODOs (cross-stage) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index cd05eae..2df7f8c 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -36,15 +36,22 @@ Login uses `Screen`. - **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a screen entered from the lobby flies in from the right; returning to the lobby reveals it from the left (back). Transitions are local (so they do not play on first load) and - collapse to nothing under reduce-motion. A per-game in-memory cache (`lib/gamecache.ts`) - renders a re-opened game instantly and refreshes it in the background, removing the - blank-loading flash on lobby ↔ game navigation. -- **Telegram theme** (Stage 17): inside the Mini App the colour scheme is forced from - `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, which leaks into the - Telegram Desktop webview and otherwise fights it), the Settings theme switcher is hidden, - the nav bar takes Telegram's background (`header_bg_color`), and a live stream dropped by - a background suspend silently reconnects on return to the foreground (the connection - banner is suppressed while hidden). + collapse to nothing under reduce-motion. Per-game and lobby in-memory caches + (`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly + and refresh in the background, removing the blank-loading flash and the lobby's "draw-in" + on lobby ↔ game navigation. +- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour + scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, + which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings + theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` / + `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the + native header **BackButton** drives back-navigation (the app's chevron is hidden in + Telegram); **HapticFeedback** fires on tile placement / commit / error; **closing + confirmation** is enabled while a game is open; **vertical swipes** (swipe-to-minimise) + are disabled so they don't fight tile drag or the board scroll; and a live stream dropped + by a background suspend reconnects silently on return — the connection banner is + suppressed while hidden and for a short grace after resume (visibilitychange + + pageshow/pagehide + Telegram `activated`/`deactivated`). ## Tiles & board @@ -66,6 +73,12 @@ Login uses `Screen`. shadow) pins to the board as the board slides down, instead of tracking the table as moves accumulate; its scrollbar gutter is reserved so the centred word column does not jitter. A move's row lists every word it formed (the main word first). +- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the + board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab + bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in + `Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard + overlays the empty area below, so the layout doesn't resize/jank; other modals stay + keyboard-aware (they size to the area above the keyboard). - **Highlights**: pending tiles use a slightly darker tile background (no outline). The last completed word gets a dark tile background — static while it is the opponent's turn (our word), and a 1 s flash when it is our turn (their word). While placing, only diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 6247051..f58157f 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -2,8 +2,9 @@ import { onMount } from 'svelte'; import { cubicOut } from 'svelte/easing'; import { app, bootstrap } from './lib/app.svelte'; - import { router } from './lib/router.svelte'; + import { navigate, router } from './lib/router.svelte'; import { t } from './lib/i18n/index.svelte'; + import { insideTelegram, telegramBackButton } from './lib/telegram'; import Toast from './components/Toast.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; @@ -19,6 +20,15 @@ void bootstrap(); }); + // Inside Telegram, drive its native header back button: show it on any sub-screen + // (everything returns to the lobby root), hide it on the lobby/login. The app's own + // back chevron is hidden in Telegram (Header.svelte) so only the native one shows. + $effect(() => { + if (!insideTelegram()) return; + const name = router.route.name; + telegramBackButton(name !== 'lobby' && name !== 'login', () => navigate('/')); + }); + // Screen transitions: the lobby is the navigation root. Entering a screen from the // lobby slides it in from the right (forward); returning to the lobby slides the // screen out to the right and reveals the lobby (back). Transitions are local, so diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index f95ff0c..e39b0a6 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -1,14 +1,19 @@

Seats

- + {{range .Seats}} - + {{end}}
SeatPlayerScoreHintsWinner
SeatPlayerScoreHints usedWinner
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
diff --git a/ui/src/components/Toast.svelte b/ui/src/components/Toast.svelte index 72e8e32..12ee496 100644 --- a/ui/src/components/Toast.svelte +++ b/ui/src/components/Toast.svelte @@ -1,9 +1,20 @@ {#if app.toast} -
{app.toast.text}
+
+ {app.toast.text} +
{/if} From 3856b34f8a177bcbf402fd37f21ca36c3c8f1d07 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 6 Jun 2026 14:55:17 +0200 Subject: [PATCH 14/28] Stage 17 docs: round-4 UI (inline profile, double-tap/drag recall, hover-zoom, animated shuffle, lines-off board) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI_DESIGN: double-tap recall vs zoom, hover-hold drag auto-zoom, placing & recall rules, grid-lines toggle (gapless checkerboard default), animated shuffle; fix the stale MakeMove/Reset description (direct ✅ button + ↩️ Reset tab, no popover). - FUNCTIONAL (+ru): optional trailing '.' in display names; profile edited inline. - PLAN: robot early band [1,5]→[3,10] (#14); round-4 refinements + deferred #2/#16. --- PLAN.md | 18 +++++++++++++++--- docs/FUNCTIONAL.md | 7 ++++--- docs/FUNCTIONAL_ru.md | 11 ++++++----- docs/UI_DESIGN.md | 37 ++++++++++++++++++++++++------------- 4 files changed, 49 insertions(+), 24 deletions(-) diff --git a/PLAN.md b/PLAN.md index 7ae8a4a..dfb8a44 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1224,9 +1224,9 @@ provided cert) at the contour caddy; prod VPN; rollback. across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20% Latin, an English game the Latin pool. Robot identities are keyed `robot--`. - **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the - band interpolates from [1,5] min at the first move to [10,90] min by ~28 moves, right-skewed by k=4, - so early moves are quick and the endgame can be long. A daytime nudge pulls the reply toward the - move's lower band. + band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min + by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime + nudge pulls the reply toward the move's lower band. - **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the actor), and the gateway already fans each event out to all of a user's live streams. @@ -1269,6 +1269,18 @@ provided cert) at the contour caddy; prod VPN; rollback. pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint button disables at zero hints; plus **board-only vertical scroll** (#9) and a **keyboard-overlay** check-word dialog (#10). + - **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised + [1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel + toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a + single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap** + or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over + a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to + their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings + toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming + ~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native + scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom; + **robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed + across the game/robot package boundary, to be picked up when that seam is added. ## Deferred TODOs (cross-stage) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 432eb39..9a3936d 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -109,9 +109,10 @@ even disguised. Nudge the player whose turn is awaited at most once per hour (th nudge is part of the game chat); the out-of-app push is delivered via the platform. ### Profile & settings *(Stage 4 / 8)* -Edit the display name (letters joined by single space / "." / "_" separators, up to -32 characters), the timezone (chosen as a UTC offset), the daily away window (on a -10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. Linking +Edit the display name (letters joined by a single space / "." / "_" separator, with an +optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the +daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the +block toggles. The profile form is edited inline (no separate edit mode). Linking an email or Telegram and merging accounts are covered under "Accounts, linking & merge" (Stage 11). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 4f1a05b..bffeb6f 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -112,11 +112,12 @@ Mini App** авторизует по подписанным `initData` плат push доставляется через платформу. ### Профиль и настройки *(Stage 4 / 8)* -Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» / -«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия -(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и -переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов -вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11). +Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / +«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от +UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с +переходом через полночь) и переключателей блокировок. Форма профиля редактируется +сразу (без отдельного режима редактирования). Привязка email и Telegram, а также +слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11). ### История и статистика *(Stage 3 / 8)* Завершённые партии архивируются в независимом от словаря виде и экспортируются diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 2df7f8c..e480580 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -62,10 +62,17 @@ Login uses `Screen`. that works consistently across browsers; no `transform`, which broke scrolling differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so they stay a constant size as the cells grow (relatively smaller at higher zoom). - **Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the - target; the custom pinch and swipe-to-open-history gestures were dropped because they - fight native scroll — history opens from the menu or a tap on the players plaque (below). - A **hint** auto-zooms centred on the hint's placement, not the top-left (Stage 17). + **Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending** + tile recalls it. On touch, placing a tile auto-zooms in centred on the target, and + **holding a dragged tile over a cell for ~1 s** auto-zooms there (Stage 17). The custom + pinch and swipe-to-open-history gestures stay dropped — they fight both native scroll and + the one-finger drag-back gesture; history opens from the menu or a tap on the players + plaque (below). A **hint** auto-zooms centred on the hint's placement, not the top-left. +- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by + dragging it onto a cell; a pending tile is taken back by a **double-tap** or by **dragging + it back onto the rack** (unzoomed board only — when zoomed the one-finger gesture scrolls). + A single tap no longer recalls (too easy to trigger); a recalled tile returns to its + original rack slot (Stage 17). - **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides) while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles @@ -86,20 +93,24 @@ Login uses `Screen`. - **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none` nothing. Default **beginner**. -- **Grid lines**: the inter-cell gap shows a contrasting `--cell-line` (darker in light, - lighter in dark) to avoid a wavy-line optical illusion. +- **Grid lines** — a Settings toggle, **default off** (Stage 17). Off: a **gapless + checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a + soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of + board width. On: the classic lined grid, where the inter-cell gap shows a contrasting + `--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion. ## Controls - **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A short tap opens a small popover above the button; a ~0.7 s hold runs the primary action - immediately. Reused by: - - **MakeMove** (appears when ≥1 tile is pending; the rack collapses its used slots and - shifts left to free room): a **🏁** button whose popover offers **Make move ✅** / - **Reset ❌**. - - **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a - remaining-count badge) — each confirmed by an **Ok ✅** popover; 🔀 Shuffle has no - label and no confirm. The under-board slot shows the **Scores: N** preview. + immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover). +- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots + and shifts left, a direct **✅** button beside the rack commits the move (no popover), and + the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab. +- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a + remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which + **animates** — tiles hop along a low parabola to their new slots (duration scaled by the + distance) with a short haptic shake. The under-board slot shows the **Scores: N** preview. ## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`) From 10412fee8e8de38e7abacc19ef94f09cb59500f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 09:17:35 +0200 Subject: [PATCH 15/28] =?UTF-8?q?Stage=2017=20round=205=20=E2=80=94=20back?= =?UTF-8?q?end/correctness=20bug=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat. - Quick-match cancel was a UI no-op (only stopped polling): add the full path (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no later robot-substituted game). NewGame dequeues on cancel and on abandon. - Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win. The winner now takes rank 1 and the viewer is placed from rank 2 — matching the game-detail screen. - Friend request to a robot: robots no longer block requests; the request stays pending and expires (friendRequestTTL), mirroring a human who ignores it. - Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a correct message; the chat nudge button disables during the hourly cooldown; the nudge note reads 'Waiting for your move!' (button keeps the Nudge action label). Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot inttest, result.ts 0-0 resignation, nudge_too_soon mapping. --- backend/internal/account/account.go | 16 +++++---- backend/internal/engine/game.go | 22 +++++++++--- backend/internal/engine/resign_test.go | 33 +++++++++++++++++ backend/internal/game/service.go | 43 ++++++++++++++++++++--- backend/internal/inttest/game_test.go | 36 +++++++++++++++++++ backend/internal/inttest/social_test.go | 32 +++++++++++++++++ backend/internal/lobby/matchmaker.go | 7 ++-- backend/internal/lobby/matchmaker_test.go | 21 +++++++++++ backend/internal/server/dto_test.go | 1 + backend/internal/server/handlers.go | 8 +++-- backend/internal/server/handlers_user.go | 14 ++++++++ gateway/internal/backendclient/api.go | 5 +++ gateway/internal/transcode/transcode.go | 13 +++++++ ui/src/game/Chat.svelte | 2 +- ui/src/game/Game.svelte | 23 +++++++++++- ui/src/lib/client.ts | 2 ++ ui/src/lib/i18n/en.ts | 4 ++- ui/src/lib/i18n/ru.ts | 4 ++- ui/src/lib/mock/client.ts | 5 +++ ui/src/lib/result.test.ts | 9 +++++ ui/src/lib/result.ts | 8 +++-- ui/src/lib/transport.ts | 3 ++ ui/src/screens/NewGame.svelte | 19 ++++++++-- 23 files changed, 301 insertions(+), 29 deletions(-) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 515294d..8fee67a 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -112,17 +112,19 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string } // 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. +// member: a KindRobot identity carrying displayName, with chat blocked but friend +// requests NOT blocked — a request to a robot is accepted as pending and, since the +// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human +// who ignores the request. 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 { + if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests { return acc, nil } stmt := table.Accounts.UPDATE( @@ -130,7 +132,7 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt, ).SET( postgres.String(displayName), postgres.Bool(true), - postgres.Bool(true), postgres.TimestampzT(time.Now().UTC()), + postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))). RETURNING(table.Accounts.AllColumns) diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go index 4b55e5c..ecbe5d0 100644 --- a/backend/internal/engine/game.go +++ b/backend/internal/engine/game.go @@ -248,17 +248,29 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) { // winning regardless of score. A missed-turn timeout reuses Resign in the game // domain, so it inherits this win/loss. func (g *Game) Resign() (MoveRecord, error) { + return g.ResignSeat(g.toMove) +} + +// ResignSeat resigns a specific seat regardless of whose turn it is, so a player +// may forfeit on the opponent's turn. The resigning seat always loses (winner() +// skips resigned seats). The turn cursor only advances when the seat that resigned +// was the one to move; resigning an off-turn seat leaves the current player's turn +// intact. It returns ErrGameOver on a finished game or for an out-of-range or +// already-resigned seat. +func (g *Game) ResignSeat(seat int) (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver } - player := g.toMove - g.resigned[player] = true - g.disposeHand(player) - rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]} + if seat < 0 || seat >= len(g.hands) || g.resigned[seat] { + return MoveRecord{}, ErrGameOver + } + g.resigned[seat] = true + g.disposeHand(seat) + rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]} g.log = append(g.log, rec) if g.activeCount() <= 1 { g.finish(EndResign) - } else { + } else if seat == g.toMove { g.advance() } return rec, nil diff --git a/backend/internal/engine/resign_test.go b/backend/internal/engine/resign_test.go index 1df334a..8c08b44 100644 --- a/backend/internal/engine/resign_test.go +++ b/backend/internal/engine/resign_test.go @@ -69,6 +69,39 @@ func TestResignTrailingPlayerLoses(t *testing.T) { } } +// TestResignSeatOffTurn covers a forfeit on the opponent's turn: after player 0 +// moves it is player 1's turn, yet player 0 resigns its own seat — the resigner +// loses, the opponent wins, and the game ends. +func TestResignSeatOffTurn(t *testing.T) { + g := openingGame(t) + + hint, ok := g.HintView() + if !ok { + t.Fatal("opening game has no hint") + } + if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves + t.Fatalf("player 0 play: %v", err) + } + if g.ToMove() != 1 { + t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove()) + } + + // Player 0 resigns although it is player 1's turn. + rec, err := g.ResignSeat(0) + if err != nil { + t.Fatalf("player 0 off-turn resign: %v", err) + } + if rec.Player != 0 || rec.Action != ActionResign { + t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action) + } + if !g.Over() || g.Reason() != EndResign { + t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason()) + } + if res := g.Result(); res.Winner != 1 { + t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner) + } +} + // TestResignOnFinishedGame rejects a second transition. func TestResignOnFinishedGame(t *testing.T) { g := newEnglishGame(t, 1) diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 780bf8e..e109929 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -171,11 +171,46 @@ func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, t } // Resign ends the game on the player's turn; the remaining player wins. +// Resign forfeits the game for the acting account. Unlike a play/exchange/pass it is +// allowed on the opponent's turn (a resignation is not a turn-scoped move), so it does +// not go through transition's turn check: it resigns the actor's own seat, whoever is to +// move. The resigning seat always loses (docs/ARCHITECTURE.md §7). func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) { - return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) { - rec, err := g.Resign() - return rec, nil, err - }) + pre, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return MoveResult{}, err + } + seat, ok := pre.seatOf(accountID) + if !ok { + return MoveResult{}, ErrNotAPlayer + } + if pre.Status != StatusActive { + return MoveResult{}, ErrFinished + } + + unlock := svc.locks.lock(gameID) + defer unlock() + + g, err := svc.liveGame(ctx, pre) + if err != nil { + return MoveResult{}, err + } + if g.Over() { + return MoveResult{}, ErrFinished + } + + rackBefore := g.Hand(seat) + rec, err := g.ResignSeat(seat) + if err != nil { + return MoveResult{}, err + } + post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats) + if err != nil { + return MoveResult{}, err + } + // A resignation carries no think time (it can happen on the opponent's turn), so it + // is intentionally excluded from the move-duration metric. + return MoveResult{Move: rec, Game: post}, nil } // GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 0ce8914..9706f13 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -299,6 +299,42 @@ func TestResignWinnerAndStats(t *testing.T) { } } +// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the +// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own +// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses +// despite leading on score. +func TestResignOnOpponentTurn(t *testing.T) { + ctx := context.Background() + svc := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + seed := openingSeed(t) + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + hint, ok := newMirror(t, seed, 2).HintView() + if !ok { + t.Fatal("no opening move") + } + if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn + t.Fatalf("p0 play: %v", err) + } + + res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn + if err != nil { + t.Fatalf("off-turn resign = %v, want nil", err) + } + if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" { + t.Fatalf("after off-turn resign: %+v", res.Game) + } + if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner { + t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats) + } +} + // TestTimeoutSweep auto-resigns an overdue game and records it as a timeout. func TestTimeoutSweep(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 5de9c3d..0317996 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -40,6 +40,38 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) { return g.ID, seats } +// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as +// pending rather than blocked: robots no longer block friend requests, so the request +// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17). +func TestFriendRequestToRobotStaysPending(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + accs := account.NewStore(testDB) + + human := provisionAccount(t) + robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie") + if err != nil { + t.Fatalf("provision robot: %v", err) + } + if robot.BlockFriendRequests { + t.Fatal("robot must not block friend requests") + } + // A request is only allowed between players who share a game. + if _, err := newGameService().Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID}, + TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }); err != nil { + t.Fatalf("create game: %v", err) + } + + if err := svc.SendFriendRequest(ctx, human, robot.ID); err != nil { + t.Fatalf("request to robot = %v, want nil (accepted as pending)", err) + } + if got, _ := svc.ListIncomingRequests(ctx, robot.ID); len(got) != 1 || got[0] != human { + t.Fatalf("robot incoming = %v, want [human]", got) + } +} + func TestFriendRequestLifecycle(t *testing.T) { ctx := context.Background() svc := newSocialService() diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 48da0dd..2342291 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -142,11 +142,14 @@ func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult return EnqueueResult{}, nil } -// Cancel removes accountID from whatever pool it waits in, reporting whether it -// was queued. +// Cancel removes accountID from whatever pool it waits in and drops any pending +// matched result, reporting whether it was queued. Clearing the result closes the +// race where the reaper substituted a robot just before the player cancelled: the +// stale game must not later surface through Poll as a game the player did not want. func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool { m.mu.Lock() defer m.mu.Unlock() + delete(m.results, accountID) variant, ok := m.queued[accountID] if !ok { return false diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index e2d145c..4092e57 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -240,6 +240,27 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) { } } +// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a +// robot just before the player cancels: Cancel must drop the pending result so the +// abandoned game never surfaces through Poll (Stage 17). +func TestMatchmakerCancelClearsPendingResult(t *testing.T) { + creator := &fakeCreator{} + mm := newTestMatchmaker(creator, uuid.New()) + base := time.Now() + mm.clock = func() time.Time { return base } + ctx := context.Background() + a := uuid.New() + + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + t.Fatalf("enqueue: %v", err) + } + mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result + mm.Cancel(ctx, a) // ... then the player cancels + if got, _ := mm.Poll(ctx, a); got.Matched { + t.Error("cancel must drop the pending substituted game; Poll still matched") + } +} + func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) { creator := &fakeCreator{} mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop()) diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index 517c8fc..619a3dc 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -47,6 +47,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"}, + "nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"}, "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index f4e64f9..e4238f3 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -69,6 +69,7 @@ func (s *Server) registerRoutes() { } if s.matchmaker != nil { u.POST("/lobby/enqueue", s.handleEnqueue) + u.POST("/lobby/cancel", s.handleCancel) u.GET("/lobby/poll", s.handlePoll) } if s.invitations != nil { @@ -200,9 +201,12 @@ func statusForError(err error) (int, string) { case errors.Is(err, session.ErrNotFound): return http.StatusUnauthorized, "session_invalid" case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong), - errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent), - errors.Is(err, social.ErrNudgeTooSoon): + errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent): return http.StatusUnprocessableEntity, "chat_rejected" + case errors.Is(err, social.ErrNudgeTooSoon): + // A too-frequent nudge is a distinct, non-content rejection — the UI must say + // "don't rush the player so often", not the chat content-rejection message. + return http.StatusConflict, "nudge_too_soon" case errors.Is(err, social.ErrSelfRelation): return http.StatusBadRequest, "self_relation" case errors.Is(err, social.ErrRequestExists): diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 7e181fc..180335a 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -153,6 +153,20 @@ func (s *Server) handleEnqueue(c *gin.Context) { c.JSON(http.StatusOK, dto) } +// handleCancel removes the caller from the auto-match pool (and drops any pending +// matched result), so a cancelled quick-match neither blocks a re-queue nor later +// surfaces a robot-substituted game the player abandoned. It is idempotent: cancelling +// when not queued is a no-op success. +func (s *Server) handleCancel(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + s.matchmaker.Cancel(c.Request.Context(), uid) + c.Status(http.StatusNoContent) +} + // handlePoll reports whether the caller has been paired since queueing. func (s *Server) handlePoll(c *gin.Context) { uid, ok := userID(c) diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 5eb17e1..4320517 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -255,6 +255,11 @@ func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) { return out, err } +// Cancel removes the caller from the auto-match pool (idempotent; 204 No Content). +func (c *Client) Cancel(ctx context.Context, userID string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/lobby/cancel", userID, "", nil, nil) +} + // ChatPost stores a chat message, forwarding the client IP for moderation. func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) { var out ChatResp diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 6cf0ca4..60a77f2 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -24,6 +24,7 @@ const ( MsgGameSubmitPlay = "game.submit_play" MsgGameState = "game.state" MsgLobbyEnqueue = "lobby.enqueue" + MsgLobbyCancel = "lobby.cancel" MsgLobbyPoll = "lobby.poll" MsgChatPost = "chat.post" MsgGamesList = "games.list" @@ -93,6 +94,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true} + r.ops[MsgLobbyCancel] = Op{Handler: cancelHandler(backend), Auth: true} r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true} r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true} r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true} @@ -233,6 +235,17 @@ func pollHandler(backend *backendclient.Client) Handler { } } +// cancelHandler removes the caller from the auto-match pool. It carries no result; +// it echoes an empty (unmatched) Match so the client has a well-formed payload. +func cancelHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + if err := backend.Cancel(ctx, req.UserID); err != nil { + return nil, err + } + return encodeMatch(backendclient.MatchResp{}), nil + } +} + func chatPostHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsChatPostRequest(req.Payload, 0) diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 0afbffe..7d12318 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -51,7 +51,7 @@ onkeydown={(e) => e.key === 'Enter' && send()} /> - + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 6b438cc..2e21877 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -89,6 +89,20 @@ const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat); const gameOver = $derived(!!view && view.game.status !== 'active'); const bagEmpty = $derived((view?.bagLen ?? 0) === 0); + // Nudge cooldown (one per hour per game, mirrored from the backend): the control is + // disabled for an hour after the player's own last nudge. nudgeTick re-evaluates it on a + // timer while the chat is open, so it re-enables without waiting for a new message. + const nudgeCooldownSecs = 3600; + let nudgeTick = $state(0); + const nudgeOnCooldown = $derived.by(() => { + void nudgeTick; + const mine = app.session?.userId ?? ''; + const last = messages.reduce( + (mx, m) => (m.kind === 'nudge' && m.senderId === mine ? Math.max(mx, m.createdAtUnix) : mx), + 0, + ); + return last > 0 && Date.now() / 1000 - last < nudgeCooldownSecs; + }); async function load() { try { @@ -145,6 +159,13 @@ else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat(); }); + // Tick the nudge cooldown while the chat is open so the control re-enables on time. + $effect(() => { + if (panel !== 'chat') return; + const h = setInterval(() => (nudgeTick += 1), 20000); + return () => clearInterval(h); + }); + function isCoarse(): boolean { return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; } @@ -708,7 +729,7 @@ {#if panel === 'chat'} (panel = 'none')}> - + {/if} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 49bfb9f..80f3543 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -65,6 +65,8 @@ export interface GatewayClient { // --- lobby --- lobbyEnqueue(variant: Variant): Promise; lobbyPoll(): Promise; + /** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */ + lobbyCancel(): Promise; // --- game --- // Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/ diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index dfa075f..63e67c5 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -94,7 +94,8 @@ export const en = { 'chat.placeholder': 'Quick message…', 'chat.send': 'Send', - 'chat.nudge': 'Nudge', + 'chat.nudge': 'Waiting for your move!', + 'chat.nudgeAction': 'Nudge', 'chat.empty': 'No messages yet.', 'chat.nudged': '{name} nudged you', @@ -155,6 +156,7 @@ export const en = { 'error.hint_unavailable': 'No hints available.', 'error.no_hint_available': 'No options with your letters.', 'error.chat_rejected': 'Message rejected (too long or contains contact info).', + 'error.nudge_too_soon': "Please don't rush your opponent so often.", 'error.game_finished': 'This game is finished.', 'error.not_a_player': 'You are not a player in this game.', 'error.already_queued': 'You are already in the queue.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index ba15548..319c606 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -95,7 +95,8 @@ export const ru: Record = { 'chat.placeholder': 'Короткое сообщение…', 'chat.send': 'Отправить', - 'chat.nudge': 'Поторопить', + 'chat.nudge': 'Жду вашего хода!', + 'chat.nudgeAction': 'Поторопить', 'chat.empty': 'Сообщений пока нет.', 'chat.nudged': '{name} торопит вас', @@ -156,6 +157,7 @@ export const ru: Record = { 'error.hint_unavailable': 'Подсказки недоступны.', 'error.no_hint_available': 'Нет вариантов с вашим набором.', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', + 'error.nudge_too_soon': 'Не стоит торопить соперника так часто.', 'error.game_finished': 'Эта игра уже завершена.', 'error.not_a_player': 'Вы не участник этой игры.', 'error.already_queued': 'Вы уже в очереди.', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index ef4a1c7..9de0d4d 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -180,6 +180,11 @@ export class MockGateway implements GatewayClient { return { matched: false }; } + async lobbyCancel(): Promise { + // Dequeue: drop the pending substitution so a cancelled quick-match never arrives. + this.pendingMatch = null; + } + // --- game --- async gameState(gameId: string, _includeAlphabet: boolean): Promise { const g = this.game(gameId); diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index f131354..3a92bb0 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -48,6 +48,15 @@ describe('resultBadge', () => { }); }); + it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => { + // The opponent won by resignation (isWinner) although neither side scored — the lobby + // must read this as a loss, matching the game-detail screen (Stage 17 regression). + expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({ + key: 'result.defeat', + emoji: '🥈', + }); + }); + it('finished four-player: places by score', () => { const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]); expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' }); diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts index 479db04..d177b94 100644 --- a/ui/src/lib/result.ts +++ b/ui/src/lib/result.ts @@ -21,9 +21,11 @@ export function resultBadge(game: GameView, myId: string): ResultBadge { if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' }; if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' }; - // Someone else won — place the viewer by score (1 + number of higher scores). - const rank = 1 + game.seats.filter((s) => s.score > (me?.score ?? 0)).length; - if (rank <= 1) return { key: 'result.victory', emoji: '🏆' }; + // Someone else won and it is not me, so I did not win — even when scores are level (a + // win by resignation or timeout can leave the winner at or below my score). The winner + // takes rank 1; place me among the remaining seats by score, starting at rank 2. + const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length; + const rank = 2 + ahead; if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' }; if (rank === 3) return { key: 'result.place3', emoji: '🥉' }; return { key: 'result.place4', emoji: '🏅' }; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index ae40b6d..23cec7a 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -80,6 +80,9 @@ export function createTransport(baseUrl: string): GatewayClient { async lobbyPoll() { return codec.decodeMatchResult(await exec('lobby.poll', codec.empty())); }, + async lobbyCancel() { + await exec('lobby.cancel', codec.empty()); + }, async gameState(id, includeAlphabet) { return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet))); diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 85ff550..0b64511 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -31,12 +31,22 @@ poll = null; } } + // cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match + // is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the + // reaper later substitutes a robot for a game the player abandoned (Stage 17 fix). + function cancelSearch() { + stop(); + searching = false; + void gateway.lobbyCancel().catch(() => {}); + navigate('/'); + } async function find(v: Variant) { searching = true; try { const r = await gateway.lobbyEnqueue(v); if (r.matched && r.game) { + searching = false; navigate(`/game/${r.game.id}`); return; } @@ -45,6 +55,7 @@ const p = await gateway.lobbyPoll(); if (p.matched && p.game) { stop(); + searching = false; navigate(`/game/${p.game.id}`); } } catch (e) { @@ -103,7 +114,11 @@ } } - onDestroy(stop); + onDestroy(() => { + stop(); + // Abandoned mid-search (navigated away without Cancel): dequeue so we don't linger. + if (searching) void gateway.lobbyCancel().catch(() => {}); + }); @@ -112,7 +127,7 @@

{t('new.searching')}

- +
{:else} {#if !guest} From 3899ffda0ff48b868094908eda9dc529aab84bc6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 09:21:22 +0200 Subject: [PATCH 16/28] Stage 17 round 5: fix robot-pool test for the new friend-request policy TestRobotPoolProvisionsRobotAccounts asserted robots block friend requests; they no longer do (a request stays pending and expires like a human ignore). Assert chat is blocked and friend requests are open. (Unblocks the integration job / contour deploy.) --- backend/internal/inttest/robot_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index 33efe14..f8ef7fa 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -96,8 +96,11 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) { if err != nil { t.Fatalf("get robot account: %v", err) } - if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests { - t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests) + // A robot blocks chat but NOT friend requests: a request to a robot stays pending and + // expires, mirroring a human who ignores it (Stage 17). + if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests { + t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)", + acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests) } } From 29d1193a0a2a8cc6ebd65de125f3f79b15d16583 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 09:34:07 +0200 Subject: [PATCH 17/28] =?UTF-8?q?Stage=2017=20round=205=20=E2=80=94=20boar?= =?UTF-8?q?d=20interaction=20&=20UI=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Even zoom: interpolate the board scroll toward a pre-clamped target as the real width grows/shrinks, so it magnifies A->B in one motion instead of lurching and snapping back near the edges/centre. Recentre only on a zoom toggle, never on a focus change — so a 2nd+ placed tile and a hovered dragged tile no longer jump the board. - Drag: highlight the aimed-at empty cell as a drop target; hover-hold auto-zoom now fires only for the first (zoom-in) hold. - Pinch zoom: two-finger spread/close toggles zoom toward the pinch midpoint (preventDefault only for two touches, so one-finger scroll stays native); a second finger aborts a drag. - Shuffle hop capped at 0.3s and disabled under reduce-motion. - Make-move is a borderless icon button, disabled while the pending word is known illegal. - Variant display names: english & russian_scrabble -> Scrabble/Скрэббл, erudit -> Erudite/Эрудит; the in-game title shows the variant name (was always 'Scrabble'). --- ui/src/game/Board.svelte | 114 ++++++++++++++++++++++++++++++++++----- ui/src/game/Game.svelte | 68 ++++++++++++++++------- ui/src/game/Rack.svelte | 7 +-- ui/src/lib/i18n/en.ts | 6 +-- ui/src/lib/i18n/ru.ts | 4 +- ui/src/lib/variants.ts | 11 +++- 6 files changed, 171 insertions(+), 39 deletions(-) diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 1a6df98..7f6637b 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -1,4 +1,5 @@ - + {#snippet menu()} {/snippet} @@ -600,6 +630,7 @@ lines={app.boardLines} locale={app.locale} {focus} + {dropTarget} oncell={onCell} ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }} onrecall={onRecall} @@ -624,10 +655,10 @@ a finished game shows the final rack greyed out and the controls disabled. -->
- +
{#if !gameOver && placement.pending.length > 0} - + {/if}
{:else} @@ -883,18 +914,19 @@ flex: 1; min-width: 0; } + /* A borderless icon button (like the tab bar), not a filled accent button — and disabled + while the pending word is known to be illegal (Stage 17). */ .make { min-width: 56px; - background: var(--accent); - color: var(--accent-text); + background: none; + color: var(--text); border: none; - border-radius: var(--radius-sm); display: grid; place-items: center; - font-size: 1.6rem; + font-size: 1.8rem; } .make:disabled { - opacity: 0.55; + opacity: 0.4; } .pop { padding: 9px 14px; diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 369e320..0a50e1b 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -26,8 +26,9 @@ // hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile // height). The duration scales with the horizontal distance — i.e. the arc length — so - // the longest swap (slot 1 ↔ 7) takes ~0.5s and shorter swaps land sooner. It runs only - // while a shuffle is in progress; ordinary reflow (placing/recalling a tile) is instant. + // the longest swap (slot 1 ↔ 7) takes ~0.3s and shorter swaps land sooner. It runs only + // while a shuffle is in progress (and motion is not reduced); ordinary reflow + // (placing/recalling a tile) is instant. function hop(node: HTMLElement, { from, to }: { from: DOMRect; to: DOMRect }, active: boolean) { const dx = from.left - to.left; const dy = from.top - to.top; @@ -36,7 +37,7 @@ const span = node.parentElement?.getBoundingClientRect().width || dist; const lift = (to.height || from.height) * 0.5; return { - duration: Math.max(160, Math.min(500, (dist / span) * 560)), + duration: Math.max(120, Math.min(300, (dist / span) * 340)), css: (t: number, u: number) => `transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`, }; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 63e67c5..c3f0b55 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -40,9 +40,9 @@ export const en = { 'new.title': 'New game', 'new.subtitle': 'Auto-match with another player', - 'new.english': 'English', - 'new.russian': 'Russian', - 'new.erudit': 'Эрудит', + 'new.english': 'Scrabble', + 'new.russian': 'Scrabble', + 'new.erudit': 'Erudite', 'new.find': 'Find a game', 'new.searching': 'Looking for an opponent…', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 319c606..f7fdeac 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -41,8 +41,8 @@ export const ru: Record = { 'new.title': 'Новая игра', 'new.subtitle': 'Автоподбор соперника', - 'new.english': 'Английский', - 'new.russian': 'Русский', + 'new.english': 'Скрэббл', + 'new.russian': 'Скрэббл', 'new.erudit': 'Эрудит', 'new.find': 'Найти игру', 'new.searching': 'Ищем соперника…', diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index 31ad921..1f21dfd 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -11,13 +11,22 @@ export interface VariantOption { label: MessageKey; } -// ALL_VARIANTS lists every variant in display order. +// ALL_VARIANTS lists every variant in display order. The labels are display names, not +// language names: both Scrabble variants render as "Scrabble"/"Скрэббл" and Erudit as +// "Erudite"/"Эрудит" (Stage 17) — the offered list is language-gated, so within one +// language the names stay distinct. export const ALL_VARIANTS: VariantOption[] = [ { id: 'english', label: 'new.english' }, { id: 'russian_scrabble', label: 'new.russian' }, { id: 'erudit', label: 'new.erudit' }, ]; +// variantNameKey returns the i18n key for a variant's display name (used by the in-game +// title and the lobby cards). +export function variantNameKey(v: Variant): MessageKey { + return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english'; +} + // VARIANT_LANGUAGE maps each variant to its game language. en -> English; // ru -> Russian + Эрудит. export const VARIANT_LANGUAGE: Record = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' }; From f916d5e0ca744fdff9816af8b2429ab8c25f8662 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 09:42:23 +0200 Subject: [PATCH 18/28] Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin game detail now shows, per robot seat, the game's deterministic play-to-win decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the ~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports, account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants (never early, never in the sleep window), PlayToWin export, and an admin render integration test asserting the intent + ETA + target appear. --- backend/internal/account/userlist.go | 13 ++++++ .../templates/pages/game_detail.gohtml | 5 ++- backend/internal/adminconsole/views.go | 11 ++++- backend/internal/game/service.go | 6 +++ backend/internal/game/store.go | 18 ++++++++ backend/internal/inttest/admin_test.go | 39 ++++++++++++++++ backend/internal/robot/strategy.go | 34 ++++++++++++++ backend/internal/robot/strategy_test.go | 31 +++++++++++++ .../internal/server/handlers_admin_console.go | 45 ++++++++++++++++++- 9 files changed, 198 insertions(+), 4 deletions(-) diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index 2f22777..e2c12fc 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -34,6 +34,19 @@ type UserFilter struct { // robotExists is the correlated subquery testing whether account a is a robot. const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')` +// IsRobot reports whether the account is a robot pool member (it carries a robot +// identity). The admin console uses it to label a game's robot seats. +func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`, + accountID).Scan(&ok) + if err != nil { + return false, fmt.Errorf("account: is-robot %s: %w", accountID, err) + } + return ok, nil +} + // userListWhere builds the shared WHERE clause and its positional args (from $1). func userListWhere(f UserFilter) (string, []any) { args := []any{f.Robots} diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml index 436fbb2..976d691 100644 --- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -17,13 +17,14 @@

Seats

- + {{range .Seats}} - + {{end}}
SeatPlayerScoreHints usedWinner
SeatPlayerScoreHints usedWinnerRobot
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}
next move {{.NextMove}}{{end}}{{end}}
+{{if .HasRobot}}

Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.

{{end}}
{{end}} {{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 8293dd9..cb92d26 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -145,9 +145,15 @@ type GameDetailView struct { UpdatedAt string FinishedAt string Seats []SeatRow + // HasRobot is true when any seat is a robot, gating the robot-target caption; + // RobotTargetPct is the configured global play-to-win rate, in percent. + HasRobot bool + RobotTargetPct int } -// SeatRow is one seat of a game. +// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's +// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the +// scheduled next-move ETA shown only while it is that robot's turn in an active game. type SeatRow struct { Seat int DisplayName string @@ -155,6 +161,9 @@ type SeatRow struct { Score int HintsUsed int Winner bool + IsRobot bool + RobotIntent string + NextMove string } // ComplaintsView is the paginated complaint review queue. diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index e109929..63c46c1 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -220,6 +220,12 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V return svc.store.GetGameVariant(ctx, gameID) } +// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's +// robot-schedule panel (the deterministic play-to-win intent and next-move ETA). +func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { + return svc.store.RobotSchedule(ctx, gameID) +} + // transition validates the actor and turn, applies op under the per-game lock and // commits the result. func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index c06c508..b09e5b4 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -651,6 +651,24 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { return row.Seed, nil } +// RobotSchedule returns a game's bag seed and current turn-start time. The admin console +// combines them with the robot strategy to show a robot seat's play-to-win intent and its +// next-move ETA. Both are server-only state, never part of the public game view. +func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { + stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt). + FROM(table.Games). + WHERE(table.Games.GameID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.Games + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return 0, time.Time{}, ErrNotFound + } + return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err) + } + return row.Seed, row.TurnStartedAt, nil +} + // projectGame builds a Game from a games row and its ordered seat rows. func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { variant, err := engine.ParseVariant(g.Variant) diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 3059643..6b77c97 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -167,6 +167,45 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) { } } +// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's +// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17). +func TestConsoleGameDetailRobotSchedule(t *testing.T) { + ctx := context.Background() + svc := newGameService() + robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester") + if err != nil { + t.Fatalf("provision robot: %v", err) + } + human := provisionAccount(t) + // Seat the robot first so it is to move (seat 0), exposing the next-move ETA. + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(), + }) + code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "") + if code != http.StatusOK { + t.Fatalf("game detail = %d, want 200", code) + } + if !strings.Contains(body, "🤖") { + t.Error("robot seat is not marked in the game detail") + } + if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") { + t.Error("robot play-to-win intent missing") + } + if !strings.Contains(body, "next move") { + t.Error("robot is to move but the next-move ETA is missing") + } + if !strings.Contains(body, "~40%") { + t.Error("robot play-to-win target caption missing") + } +} + // consoleDo issues a request to h, optionally with an Origin header, and returns // the status and body. Form bodies are sent as application/x-www-form-urlencoded. func consoleDo(h http.Handler, method, target, body, origin string) (int, string) { diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index 7c9b952..4219863 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -114,6 +114,40 @@ func playToWin(seed int64) bool { return mix(seed, "win")%100 < playToWinPercent } +// PlayToWin exposes the once-per-game play-to-win decision for a game's bag seed, for the +// admin console (it is deterministic and fixed for the whole game). +func PlayToWin(seed int64) bool { return playToWin(seed) } + +// PlayToWinTargetPercent is the configured probability, in percent, that a robot plays to +// win in any given game (the admin console shows it alongside the per-game decision). +const PlayToWinTargetPercent = playToWinPercent + +// NextMoveAt is the deterministic instant the robot is scheduled to play the move at +// moveCount, given when the turn started and the opponent's timezone (which anchors the +// robot's sleep window). It is the sampled think-time delay, deferred to the end of the +// sleep window when it would otherwise land while the robot is asleep. The driver acts on +// a scan tick, so the real move lands at the first scan at or after this instant. It is +// meaningful only on the robot's own turn; the admin console surfaces it as an ETA. +func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time { + t := turnStartedAt.Add(moveDelay(seed, moveCount)) + drift := sleepDrift(seed) + if asleep(opponentTZ, drift, t) { + t = wakeAfter(opponentTZ, drift, t) + } + return t +} + +// wakeAfter returns the first instant at or after t when the robot is awake — the local +// hour reaches sleepEndHour in the opponent's drifted timezone — converted back to UTC. +func wakeAfter(opponentTZ string, drift time.Duration, t time.Time) time.Time { + local := t.In(loadLocation(opponentTZ)).Add(drift) + wake := time.Date(local.Year(), local.Month(), local.Day(), sleepEndHour, 0, 0, 0, local.Location()) + if !wake.After(local) { + wake = wake.Add(24 * time.Hour) + } + return wake.Add(-drift).UTC() +} + // 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 diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go index b728d00..91092bb 100644 --- a/backend/internal/robot/strategy_test.go +++ b/backend/internal/robot/strategy_test.go @@ -207,6 +207,37 @@ func TestMixDeterministic(t *testing.T) { } } +// TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never +// earlier than the sampled think-time delay, and it never lands while the robot is asleep +// (a delay that would fall in the sleep window is deferred to the wake time). +func TestNextMoveAt(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for seed := int64(1); seed <= 500; seed++ { + for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day + start := base.Add(time.Duration(h) * time.Hour) + at := NextMoveAt(seed, 3, start, "UTC") + if at.Before(start.Add(moveDelay(seed, 3))) { + t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at) + } + if asleep("UTC", sleepDrift(seed), at) { + t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at) + } + } + } +} + +// TestPlayToWinExport checks the exported decision matches the internal one and the target. +func TestPlayToWinExport(t *testing.T) { + for seed := int64(1); seed <= 200; seed++ { + if PlayToWin(seed) != playToWin(seed) { + t.Fatalf("PlayToWin(%d) != playToWin", seed) + } + } + if PlayToWinTargetPercent != playToWinPercent { + t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent) + } +} + // plays builds candidate plays carrying only the given scores (ranked as passed). func plays(scores ...int) []engine.MoveRecord { out := make([]engine.MoveRecord, len(scores)) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 91da11c..5631c8b 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -18,6 +18,7 @@ import ( "scrabble/backend/internal/adminconsole" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/robot" ) // adminPageSize is the page size of the admin console's paginated lists. @@ -248,16 +249,58 @@ func (s *Server) consoleGameDetail(c *gin.Context) { MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt), FinishedAt: fmtTimePtr(g.FinishedAt), } + // Resolve seats and detect robot seats; capture the human opponent's timezone, which + // anchors the robot's sleep window for the next-move ETA. + oppTZ := "" for _, seat := range g.Seats { row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner} - if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil { + acc, accErr := s.accounts.GetByID(ctx, seat.AccountID) + if accErr == nil { row.DisplayName = acc.DisplayName } + if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot { + row.IsRobot = true + view.HasRobot = true + } else if accErr == nil { + oppTZ = acc.TimeZone + } view.Seats = append(view.Seats, row) } + // For each robot seat, surface the game's deterministic play-to-win intent and — while + // it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed. + if view.HasRobot { + view.RobotTargetPct = robot.PlayToWinTargetPercent + if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil { + now := time.Now().UTC() + for i := range view.Seats { + if !view.Seats[i].IsRobot { + continue + } + if robot.PlayToWin(seed) { + view.Seats[i].RobotIntent = "play to win" + } else { + view.Seats[i].RobotIntent = "play to lose" + } + if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat { + view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now) + } + } + } + } s.renderConsole(c, "game_detail", "games", "Game", view) } +// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a +// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)". +func robotETA(at, now time.Time) string { + mins := int(at.Sub(now).Round(time.Minute).Minutes()) + rel := fmt.Sprintf("in ~%d min", mins) + if mins <= 0 { + rel = "due now" + } + return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel) +} + // consoleComplaints renders the paginated complaint review queue. func (s *Server) consoleComplaints(c *gin.Context) { ctx := c.Request.Context() From a420d6a2cd26ffb4ec799015429edcc7feddb639 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 09:48:08 +0200 Subject: [PATCH 19/28] Stage 17 round 5 docs: bake the bug fixes + UI polish + L2 into live docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ARCHITECTURE: resign on the opponent's turn (ResignSeat + turn-check bypass); robots block chat but accept-and-ignore friend requests; quick-match /lobby/cancel; the admin robot play-to-win intent + next-move ETA panel. - UI_DESIGN: even A->B zoom (recentre only on zoom-in), pinch, drop-target highlight, shuffle ≤0.3s + reduce-motion, borderless make-move disabled on illegal, variant title. - FUNCTIONAL (+ru): variant display names (Scrabble/Erudite); robot ignores friend requests. - PLAN: round-5 refinements bullet (+ the bilingual two-Scrabble open edge). --- PLAN.md | 19 +++++++++++++++++++ docs/ARCHITECTURE.md | 17 +++++++++++++---- docs/FUNCTIONAL.md | 11 +++++++---- docs/FUNCTIONAL_ru.md | 13 ++++++++----- docs/UI_DESIGN.md | 33 +++++++++++++++++++++------------ 5 files changed, 68 insertions(+), 25 deletions(-) diff --git a/PLAN.md b/PLAN.md index dfb8a44..b8b28de 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1281,6 +1281,25 @@ provided cert) at the contour caddy; prod VPN; rollback. scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom; **robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed across the game/robot package boundary, to be picked up when that seam is added. + - **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn** + now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit + the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op + (only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the + matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later + robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win — + `result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend + request to a robot** is accepted as pending and expires like a human ignore (robots no longer set + `BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the + chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish: + **even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no + lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch + zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop + capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known- + illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit + → Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game + card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's + turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would + show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites. ## Deferred TODOs (cross-stage) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 47aa02d..46045bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -235,7 +235,10 @@ Key points: applying the end-game rack-value adjustment, or a resignation. On a **resignation the resigner keeps their accumulated score (no rack adjustment) and never wins**: the win goes to the highest score among the remaining seats, - unconditionally the other player in a two-player game. The engine exposes a + unconditionally the other player in a two-player game. A player may resign **on the + opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)` + resigns that player's own seat whoever is to move, and the game domain skips the turn + check for resign (Stage 17). The engine exposes a decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/ `HintView`/`Hand`) so `internal/game` drives it without importing the solver. - The **game domain** (`internal/game`) owns everything the engine does not — @@ -301,9 +304,10 @@ from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver (`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same behaviour on every scan and after a restart — the same philosophy as journal replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed -`robot--` and provisioned at startup with chat and friend requests -blocked — backs the human-like names; those two profile toggles are all the -friend/DM blocking requires (there is no DM surface; chat is per-game). Names are +`robot--` and provisioned at startup with **chat blocked but friend +requests open** — a request to a robot is accepted as pending and expires unanswered +(the robot never responds), mirroring a human who ignores it (Stage 17); the chat +block backs the human-like names (there is no DM surface; chat is per-game). Names are **composed per language** from a first-name pool (32 full + 32 colloquial forms) and a surname pool (gender-agreed for Russian) in one of three forms (first only / first + surname initial / first + full surname), deterministically per pool slot so @@ -331,6 +335,8 @@ English game the Latin pool. - **Observability**: robot accounts accrue ordinary statistics (§9) — the authoritative balance metric (target ≈ 40% robot wins) — and a `robot_games_finished_total` OTel counter plus a per-finish log give a live view. + The **admin game card** surfaces each robot seat's per-game play-to-win intent (from + the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17). ## 8. Lobby & social @@ -342,6 +348,9 @@ English game the Latin pool. robot (§7) and starts the game. On a pairing or substitution the matchmaker emits a **match-found** notification (§10), delivered over the live stream; `Poll` remains as a fallback for a client that is not currently streaming. + **Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any + pending matched result, so a cancelled quick-match is dequeued rather than left for + the reaper to robot-substitute (Stage 17). - **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric, SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 9a3936d..771ae57 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -55,9 +55,11 @@ two accounts share a game still in progress. ### Lobby & matchmaking *(Stage 4 / 15)* Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are -limited to the languages the player's sign-in service supports (English → English; -Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is -unrestricted). This gates only **starting** a new game — both auto-match and a friend +limited to the languages the player's sign-in service supports (English → Scrabble; +Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is +unrestricted). Variants are shown by their **display name** — both Scrabble variants read +"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and +the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend invitation — so a player still sees and plays existing games of any language. Auto-match (always 2 players) joins a per-variant pool and is paired with the next waiting human; after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are @@ -92,7 +94,8 @@ and plays at a human pace — short thinking times for most moves, the occasiona one, and a night-time pause that tracks the player's own day. It answers a nudge within a few minutes and nudges back when the player has been away a long time. It carries a human-like, language-appropriate name (a Russian game draws mostly Russian -names) and neither chats nor accepts friend requests. +names); it does not chat, and **silently ignores friend requests** — a request to a +robot stays pending and expires, exactly like a human who never responds. ### Social: friends, block, chat, nudge *(Stage 4 / 8)* Become friends in two ways: redeem a **one-time code** the other player issues (six diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index bffeb6f..cbbd2aa 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -56,9 +56,11 @@ Mini App** авторизует по подписанным `initData` плат ### Лобби и подбор *(Stage 4 / 15)* Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра** -ограничены языками, которые поддерживает сервис входа игрока (английский → English; -русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не -ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и +ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; +русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не +ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble +читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса), +и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на любом языке. Авто-подбор (всегда 2 игрока) встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с @@ -93,8 +95,9 @@ Mini App** авторизует по подписанным `initData` плат поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее -языку партии (в русской партии — в основном русские имена), не общается в чате и не -принимает заявки в друзья. +языку партии (в русской партии — в основном русские имена); не общается в чате и +**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает, +ровно как у человека, который не отвечает. ### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)* Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index e480580..3ba425a 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -63,16 +63,22 @@ Login uses `Screen`. differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so they stay a constant size as the cells grow (relatively smaller at higher zoom). **Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending** - tile recalls it. On touch, placing a tile auto-zooms in centred on the target, and - **holding a dragged tile over a cell for ~1 s** auto-zooms there (Stage 17). The custom - pinch and swipe-to-open-history gestures stay dropped — they fight both native scroll and - the one-finger drag-back gesture; history opens from the menu or a tap on the players - plaque (below). A **hint** auto-zooms centred on the hint's placement, not the top-left. + tile recalls it. **Pinch** zooms toward the pinch midpoint (a two-finger gesture; + preventDefault fires only for two touches, so one-finger scroll stays native, and a second + finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target** + as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping + back near the edges (Stage 17). It **recentres only on a zoom-in** — placing a 2nd+ tile or + hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms + in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there + the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll); + history opens from the menu or a tap on the players plaque (below). A **hint** auto-zooms + centred on the hint's placement, not the top-left. - **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by - dragging it onto a cell; a pending tile is taken back by a **double-tap** or by **dragging - it back onto the rack** (unzoomed board only — when zoomed the one-finger gesture scrolls). - A single tap no longer recalls (too easy to trigger); a recalled tile returns to its - original rack slot (Stage 17). + dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty + cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a + **double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed + the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a + recalled tile returns to its original rack slot (Stage 17). - **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides) while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles @@ -105,12 +111,15 @@ Login uses `Screen`. short tap opens a small popover above the button; a ~0.7 s hold runs the primary action immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover). - **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots - and shifts left, a direct **✅** button beside the rack commits the move (no popover), and - the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab. + and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent + button) beside the rack commits the move — no popover, and disabled while the pending word + is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab. - **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which **animates** — tiles hop along a low parabola to their new slots (duration scaled by the - distance) with a short haptic shake. The under-board slot shows the **Scores: N** preview. + distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The + under-board slot shows the **Scores: N** preview. The screen **title** is the variant's + display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble". ## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`) From 512ad4dfb97bc62c33d8d6cf0d7a2b41952c6446 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 11:18:25 +0200 Subject: [PATCH 20/28] Stage 17 round 6 (cluster 1): profile, tap flash, variant naming, chat/nudge by turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile: drop the hint-balance line. - Board: no mobile tap flash on a cell tap (-webkit-tap-highlight-color: transparent), matching the web click; the only intentional cell animation stays the last-word flash. - Variant names keyed by the game's alphabet, not the UI language: english -> Scrabble always, russian_scrabble -> Скрэббл always (unlocalized, never collide), erudit localized. - Chat/nudge are mutually exclusive by turn: the message field + Send show on your turn, the nudge replaces them on the opponent's turn; while the nudge cooldown is active the button is disabled with a grey 'awaiting reply' caption to its left. --- ui/e2e/social.spec.ts | 10 +++++---- ui/src/game/Board.svelte | 3 +++ ui/src/game/Chat.svelte | 40 ++++++++++++++++++++++++----------- ui/src/game/Game.svelte | 2 +- ui/src/lib/i18n/en.ts | 3 ++- ui/src/lib/i18n/ru.ts | 3 ++- ui/src/lib/variants.ts | 9 ++++---- ui/src/screens/Profile.svelte | 11 ---------- 8 files changed, 47 insertions(+), 34 deletions(-) diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 331df8b..33cdd70 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -165,12 +165,14 @@ test('link account: the Telegram web sign-in control is offered in a browser', a await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); }); -test('chat send and nudge are icon buttons', async ({ page }) => { +test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => { await loginLobby(page); - await page.getByRole('button', { name: /Ann/ }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn await page.locator('.burger').first().click(); await page.getByRole('button', { name: 'Chat' }).click(); - // Icon-only controls expose their action through the aria-label. + // On your turn the message field + Send are shown and the nudge is hidden (Stage 17); + // chat and nudge are mutually exclusive by turn. Icon-only controls expose their action + // through the aria-label. await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Nudge' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0); }); diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 7f6637b..a434519 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -250,6 +250,9 @@ border-radius: 1px; background: var(--cell-bg); color: var(--prem-text); + /* No mobile tap flash on a cell tap (parity with the web click; the only intentional + cell animation is the last-word .flash highlight). */ + -webkit-tap-highlight-color: transparent; padding: 0; overflow: hidden; font-size: 0; diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 7d12318..fb1ddb9 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -6,16 +6,20 @@ messages, myId, busy, - canNudge = true, + myTurn = false, + nudgeOnCooldown = false, onsend, onnudge, }: { messages: ChatMessage[]; myId: string; busy: boolean; - // Nudging only makes sense while waiting on the opponent; it is disabled on the - // player's own turn (there is no one to hurry along). - canNudge?: boolean; + // Chat and nudge are mutually exclusive by turn (Stage 17): on the player's own turn the + // message field + send are shown (and nudging makes no sense — there is no one to + // hurry); on the opponent's turn only the nudge button shows. While the hourly nudge + // cooldown is active the nudge is disabled with an "awaiting reply" caption. + myTurn?: boolean; + nudgeOnCooldown?: boolean; onsend: (text: string) => void; onnudge: () => void; } = $props(); @@ -44,14 +48,18 @@ {/each}
- e.key === 'Enter' && send()} - /> - - + {#if myTurn} + e.key === 'Enter' && send()} + /> + + {:else} + {#if nudgeOnCooldown}{t('chat.awaitingReply')}{/if} + + {/if}
@@ -99,6 +107,14 @@ .input { display: flex; gap: 6px; + align-items: center; + } + /* The cooldown caption sits to the left of the disabled nudge button. */ + .cooldown { + flex: 1; + text-align: right; + color: var(--text-muted); + font-size: 0.85rem; } .input input { flex: 1; diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 9fa01c9..7c07d2a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -760,7 +760,7 @@ {#if panel === 'chat'} (panel = 'none')}> - + {/if} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index c3f0b55..3261d38 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -41,7 +41,7 @@ export const en = { 'new.title': 'New game', 'new.subtitle': 'Auto-match with another player', 'new.english': 'Scrabble', - 'new.russian': 'Scrabble', + 'new.russian': 'Скрэббл', 'new.erudit': 'Erudite', 'new.find': 'Find a game', 'new.searching': 'Looking for an opponent…', @@ -96,6 +96,7 @@ export const en = { 'chat.send': 'Send', 'chat.nudge': 'Waiting for your move!', 'chat.nudgeAction': 'Nudge', + 'chat.awaitingReply': "Waiting for the opponent's reply", 'chat.empty': 'No messages yet.', 'chat.nudged': '{name} nudged you', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index f7fdeac..10846f5 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -41,7 +41,7 @@ export const ru: Record = { 'new.title': 'Новая игра', 'new.subtitle': 'Автоподбор соперника', - 'new.english': 'Скрэббл', + 'new.english': 'Scrabble', 'new.russian': 'Скрэббл', 'new.erudit': 'Эрудит', 'new.find': 'Найти игру', @@ -97,6 +97,7 @@ export const ru: Record = { 'chat.send': 'Отправить', 'chat.nudge': 'Жду вашего хода!', 'chat.nudgeAction': 'Поторопить', + 'chat.awaitingReply': 'Ждём реакцию соперника', 'chat.empty': 'Сообщений пока нет.', 'chat.nudged': '{name} торопит вас', diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index 1f21dfd..a622c2b 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -11,10 +11,11 @@ export interface VariantOption { label: MessageKey; } -// ALL_VARIANTS lists every variant in display order. The labels are display names, not -// language names: both Scrabble variants render as "Scrabble"/"Скрэббл" and Erudit as -// "Erudite"/"Эрудит" (Stage 17) — the offered list is language-gated, so within one -// language the names stay distinct. +// ALL_VARIANTS lists every variant in display order. The labels are display names keyed by +// the game's alphabet, not the interface language: the English-alphabet game is always +// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the +// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит" +// (Stage 17). export const ALL_VARIANTS: VariantOption[] = [ { id: 'english', label: 'new.english' }, { id: 'russian_scrabble', label: 'new.russian' }, diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 999e10e..8cc48d2 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -166,8 +166,6 @@
{p.displayName}
{#if p.isGuest}{t('profile.guest')}{/if} -
{t('profile.hintBalance')}{p.hintBalance}
- {#if p.isGuest}

{t('profile.guestLocked')}

{:else} @@ -284,15 +282,6 @@ color: var(--text-muted); font-size: 0.8rem; } - .hintbal { - display: flex; - justify-content: space-between; - color: var(--text-muted); - } - .hintbal b { - color: var(--text); - font-weight: 600; - } .muted { color: var(--text-muted); font-size: 0.9rem; From 2cb2b57cdbae15006d5e6704ba04df5e9e775303 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 11:23:43 +0200 Subject: [PATCH 21/28] Stage 17 round 6 (#10 backend): enforce chat only on your turn PostMessage now rejects a chat sent on a finished game or when it is not the sender's turn (ErrChatNotYourTurn -> 409 chat_not_your_turn), matching the UI where the message field is hidden off-turn and only the nudge shows. Existing chat tests post on the to-move seat and are unaffected; adds an off-turn-rejection integration test + the dto mapping case + the UI error message. --- backend/internal/inttest/social_test.go | 14 ++++++++++++++ backend/internal/server/dto_test.go | 1 + backend/internal/server/handlers.go | 2 ++ backend/internal/social/chat.go | 13 +++++++++++-- backend/internal/social/social.go | 4 ++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + 7 files changed, 34 insertions(+), 2 deletions(-) diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 0317996..ac44613 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -314,6 +314,20 @@ func TestChatRejectsBadContent(t *testing.T) { } } +// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17): +// the player to move can post, the waiting player gets ErrChatNotYourTurn. +func TestChatOnlyOnYourTurn(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening + if _, err := svc.PostMessage(ctx, gameID, seats[1], "hi", ""); !errors.Is(err, social.ErrChatNotYourTurn) { + t.Fatalf("off-turn chat = %v, want ErrChatNotYourTurn", err) + } + if _, err := svc.PostMessage(ctx, gameID, seats[0], "hi", ""); err != nil { + t.Fatalf("on-turn chat = %v, want nil", err) + } +} + func TestNudgeRulesAndRateLimit(t *testing.T) { ctx := context.Background() svc := newSocialService() diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index 619a3dc..a5d2c66 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -48,6 +48,7 @@ func TestStatusForError(t *testing.T) { "not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"}, "nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"}, "nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"}, + "chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"}, "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index e4238f3..c4104b5 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -207,6 +207,8 @@ func statusForError(err error) (int, string) { // A too-frequent nudge is a distinct, non-content rejection — the UI must say // "don't rush the player so often", not the chat content-rejection message. return http.StatusConflict, "nudge_too_soon" + case errors.Is(err, social.ErrChatNotYourTurn): + return http.StatusConflict, "chat_not_your_turn" case errors.Is(err, social.ErrSelfRelation): return http.StatusBadRequest, "self_relation" case errors.Is(err, social.ErrRequestExists): diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 387440e..78e6f24 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -49,13 +49,22 @@ type Message struct { // rune limit, and free of links/emails/phone numbers (the content filter). The // gateway-forwarded senderIP is validated and stored for moderation. func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) { - seats, _, _, err := svc.games.Participants(ctx, gameID) + seats, toMove, status, err := svc.games.Participants(ctx, gameID) if err != nil { return Message{}, err } - if !slices.Contains(seats, senderID) { + idx := slices.Index(seats, senderID) + if idx < 0 { return Message{}, ErrNotParticipant } + // Chat is allowed only on the sender's own turn in an active game; the opponent's-turn + // control is the nudge (Stage 17). + if status != statusActive { + return Message{}, ErrGameNotActive + } + if idx != toMove { + return Message{}, ErrChatNotYourTurn + } sender, err := svc.accounts.GetByID(ctx, senderID) if err != nil { return Message{}, err diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 43002c2..3ea5c28 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -67,6 +67,10 @@ var ( ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour") // ErrGameNotActive is returned when a nudge is attempted on a finished game. ErrGameNotActive = errors.New("social: game is not active") + // ErrChatNotYourTurn is returned when a chat message is sent while it is not the + // sender's turn — chat is allowed only on your own turn (the opponent's-turn control + // is the nudge, Stage 17). + ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn") ) // Service is the social domain. It is the only writer of the friendships, blocks diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3261d38..03189ce 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -158,6 +158,7 @@ export const en = { 'error.no_hint_available': 'No options with your letters.', 'error.chat_rejected': 'Message rejected (too long or contains contact info).', 'error.nudge_too_soon': "Please don't rush your opponent so often.", + 'error.chat_not_your_turn': 'You can chat only on your turn.', 'error.game_finished': 'This game is finished.', 'error.not_a_player': 'You are not a player in this game.', 'error.already_queued': 'You are already in the queue.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 10846f5..c7cec61 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -159,6 +159,7 @@ export const ru: Record = { 'error.no_hint_available': 'Нет вариантов с вашим набором.', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.nudge_too_soon': 'Не стоит торопить соперника так часто.', + 'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.', 'error.game_finished': 'Эта игра уже завершена.', 'error.not_a_player': 'Вы не участник этой игры.', 'error.already_queued': 'Вы уже в очереди.', From cdf616d6c480197d10d1cd83064782198765a7bd Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 11:32:08 +0200 Subject: [PATCH 22/28] Stage 17 round 6 (#7): reset the nudge cooldown once the player acts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hourly nudge cooldown now clears as soon as the sender has moved or posted a chat since their last nudge — engagement lifts the 'don't spam' limit. Backend: Nudge checks game.LastMoveAt + the sender's last non-nudge chat against the last nudge time (GameReader gains LastMoveAt). UI: nudgeOnCooldown mirrors it — a chat reset is read from the message list, a move is tracked client-side (lastActedAt on commit/pass/exchange; the backend stays authoritative across a reload). Integration test covers the reset. --- backend/internal/game/service.go | 7 ++++ backend/internal/game/store.go | 19 ++++++++++ backend/internal/inttest/social_test.go | 30 ++++++++++++++++ backend/internal/social/chat.go | 47 ++++++++++++++++++++++++- backend/internal/social/social.go | 3 ++ ui/src/game/Game.svelte | 22 +++++++++--- 6 files changed, 122 insertions(+), 6 deletions(-) diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 63c46c1..0010f29 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -226,6 +226,13 @@ func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed i return svc.store.RobotSchedule(ctx, gameID) } +// LastMoveAt returns the time of an account's most recent move in a game (and whether it +// has moved). The social service uses it to reset the nudge cooldown once a player has +// taken a turn (Stage 17). +func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { + return svc.store.LastMoveAt(ctx, gameID, accountID) +} + // transition validates the actor and turn, applies op under the per-game lock and // commits the result. func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index b09e5b4..d97a2ea 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -651,6 +651,25 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { return row.Seed, nil } +// LastMoveAt returns the time of the account's most recent move in the game and true, or +// the zero time and false when it has not moved. The social service uses it to reset the +// nudge cooldown once the player has taken a turn (Stage 17). +func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { + var at sql.NullTime + err := s.db.QueryRowContext(ctx, + `SELECT MAX(m.created_at) FROM backend.game_moves m + JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat + WHERE m.game_id = $1 AND p.account_id = $2`, + gameID, accountID).Scan(&at) + if err != nil { + return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err) + } + if !at.Valid { + return time.Time{}, false, nil + } + return at.Time, true, nil +} + // RobotSchedule returns a game's bag seed and current turn-start time. The admin console // combines them with the robot strategy to show a robot seat's play-to-win intent and its // next-move ETA. Both are server-only state, never part of the public game view. diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index ac44613..33908fa 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -353,3 +353,33 @@ func TestNudgeRulesAndRateLimit(t *testing.T) { t.Fatalf("nudge after window: %v", err) } } + +// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has +// acted (moved or chatted) since their last nudge, even within the hour (Stage 17). +func TestNudgeCooldownResetsOnAction(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gsvc := newGameService() + gameID, seats := newGameWithSeats(t, 2) // seat 0 to move + + if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges + t.Fatalf("nudge: %v", err) + } + if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) { + t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err) + } + // Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes. + if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil { + t.Fatalf("seat0 pass: %v", err) + } + if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil { + t.Fatalf("seat1 chat: %v", err) + } + if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil { + t.Fatalf("seat1 pass: %v", err) + } + // Back on the opponent's turn, the cooldown is reset by the action since the nudge. + if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { + t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err) + } +} diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 78e6f24..0d9edc8 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -114,7 +114,15 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess return Message{}, err } if ok && svc.now().Sub(last) < nudgeInterval { - return Message{}, ErrNudgeTooSoon + // The cooldown resets once the sender has acted (moved or chatted) since the last + // nudge — engagement clears the "don't spam" limit (Stage 17). + acted, err := svc.actedSince(ctx, gameID, senderID, last) + if err != nil { + return Message{}, err + } + if !acted { + return Message{}, ErrNudgeTooSoon + } } msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil) if err != nil { @@ -127,6 +135,22 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess return msg, nil } +// actedSince reports whether senderID made a move or posted a chat message in the game +// after t — the events that reset the nudge cooldown (Stage 17). +func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) { + if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil { + return false, err + } else if ok && mv.After(t) { + return true, nil + } + if msg, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil { + return false, err + } else if ok && msg.After(t) { + return true, nil + } + return false, nil +} + // emitChat pushes a chat message to every seated player except the sender // (best-effort live delivery; the recipients still read it via Messages). func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) { @@ -261,6 +285,27 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti return row.CreatedAt, true, nil } +// lastMessageAt returns the time of senderID's most recent non-nudge chat message in +// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale +// nudge no longer blocks a new one (Stage 17). +func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) { + stmt := postgres.SELECT(table.ChatMessages.CreatedAt). + FROM(table.ChatMessages). + WHERE( + table.ChatMessages.GameID.EQ(postgres.UUID(gameID)). + AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))). + AND(table.ChatMessages.Kind.EQ(postgres.String(kindMessage))), + ).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1) + var row model.ChatMessages + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return time.Time{}, false, nil + } + return time.Time{}, false, fmt.Errorf("social: last message: %w", err) + } + return row.CreatedAt, true, nil +} + // messageFromRow projects a generated row into the public Message. func messageFromRow(r model.ChatMessages) Message { m := Message{ diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 3ea5c28..1f0aaa2 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -28,6 +28,9 @@ type GameReader interface { // SharedGame reports whether two accounts are seated together in any game // (active or finished); it gates the "befriend an opponent" request path. SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) + // LastMoveAt is the time of an account's most recent move in a game (and whether it + // has moved); the nudge cooldown resets once the player has taken a turn. + LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) } // Sentinel errors returned by the service. diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 7c07d2a..beafaac 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -95,14 +95,23 @@ // timer while the chat is open, so it re-enables without waiting for a new message. const nudgeCooldownSecs = 3600; let nudgeTick = $state(0); + // Unix seconds of the player's own last move, which resets the nudge cooldown (mirrors the + // backend, Stage 17). A chat reset is read from `messages`; a move is tracked client-side + // (the backend stays authoritative across a reload). + let lastActedAt = $state(0); const nudgeOnCooldown = $derived.by(() => { void nudgeTick; const mine = app.session?.userId ?? ''; - const last = messages.reduce( - (mx, m) => (m.kind === 'nudge' && m.senderId === mine ? Math.max(mx, m.createdAtUnix) : mx), - 0, - ); - return last > 0 && Date.now() / 1000 - last < nudgeCooldownSecs; + let lastNudge = 0; + let lastChat = 0; + for (const m of messages) { + if (m.senderId !== mine) continue; + if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix); + else lastChat = Math.max(lastChat, m.createdAtUnix); + } + if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false; + // Engagement since the nudge clears the cooldown: a chat or a move. + return lastChat <= lastNudge && lastActedAt <= lastNudge; }); async function load() { @@ -361,6 +370,7 @@ busy = true; try { await gateway.submitPlay(id, sub.dir, sub.tiles, variant); + lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown telegramHaptic('success'); zoomed = false; await load(); @@ -381,6 +391,7 @@ busy = true; try { await gateway.pass(id); + lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown await load(); } catch (e) { handleError(e); @@ -461,6 +472,7 @@ busy = true; try { await gateway.exchange(id, tiles, variant); + lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown await load(); } catch (e) { handleError(e); From 74683f294f2666627eb17c9bb368b9a89c73815f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 11:39:31 +0200 Subject: [PATCH 23/28] Stage 17 round 6 (#13/About): About screen content + app version from git describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - About screen: prominent localized title (Scrabble / Эрудит (Скрэббл)), a rules link (en/ru Wikipedia), and the Random-game / Game-with-friends sections; copy lives in a shared aboutContent module (the landing will reuse it). The random-game move limit inlines the 24h auto-match clock. - App version: Vite define __APP_VERSION__ from VITE_APP_VERSION (default 'dev'), wired as a Docker build-arg sourced from `git describe --tags --always` in the deploy step — no manual version bumps. The fallback keeps a plain/local build working. --- .gitea/workflows/ci.yaml | 3 ++ deploy/docker-compose.yml | 1 + gateway/Dockerfile | 5 ++- ui/src/lib/aboutContent.ts | 62 +++++++++++++++++++++++++++++++++++++ ui/src/screens/About.svelte | 62 +++++++++++++++++++++++++++++++++++-- ui/src/vite-env.d.ts | 3 ++ ui/vite.config.ts | 6 ++++ 7 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 ui/src/lib/aboutContent.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 42cb76a..c5e381d 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -285,6 +285,9 @@ jobs: mkdir -p "$conf" cp -r caddy otelcol prometheus tempo grafana "$conf"/ export SCRABBLE_CONFIG_DIR="$conf" + # App version for the About screen: the git tag if present, else the short SHA + # (the test checkout is shallow/untagged, so this is the SHA here — fine). + export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)" docker compose --ansi never build --progress plain docker compose --ansi never up -d --remove-orphans # The config-only services bind-mount the reseeded config dir. A plain `up -d` diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 66c96f3..7836d94 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -79,6 +79,7 @@ services: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} + VITE_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped depends_on: [backend] environment: diff --git a/gateway/Dockerfile b/gateway/Dockerfile index bb0dd60..4a22bad 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -17,12 +17,15 @@ WORKDIR /ui RUN corepack enable && corepack prepare pnpm@11.0.9 --activate # Prod UI build vars (Vite reads VITE_-prefixed env at build; baked into the bundle). +# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev"). ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_LINK= ARG VITE_GATEWAY_URL= +ARG VITE_APP_VERSION= ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ - VITE_GATEWAY_URL=$VITE_GATEWAY_URL + VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ + VITE_APP_VERSION=$VITE_APP_VERSION # Install with the lockfile first (the workspace file carries pnpm's build-script # approval for esbuild), then build. Committed src/gen/ means no codegen here. diff --git a/ui/src/lib/aboutContent.ts b/ui/src/lib/aboutContent.ts new file mode 100644 index 0000000..dd43297 --- /dev/null +++ b/ui/src/lib/aboutContent.ts @@ -0,0 +1,62 @@ +// Localised "About" / landing copy, shared by the About screen and the public landing +// page (Stage 17). Kept out of the flat i18n catalog because it is structured (a heading, +// a rules link, two bulleted sections) and only used in these two long-form places. + +import type { Locale } from './i18n/index.svelte'; + +export interface AboutContent { + /** Prominent heading: "Scrabble" / "Эрудит (Скрэббл)". */ + title: string; + rulesUrl: string; + /** Text before the rules link. */ + rulesPrefix: string; + /** The rules link label. */ + rulesLink: string; + randomTitle: string; + /** The "respect the opponent's time" note (rendered with a ❗️ prefix). */ + randomRespect: string; + random: string[]; + friendsTitle: string; + friends: string[]; +} + +/** + * aboutContent returns the localised About/landing copy. hours is the auto-match move clock + * (backend game.DefaultTurnTimeout), inlined into the random-game time-limit bullet. + */ +export function aboutContent(locale: Locale, hours: number): AboutContent { + if (locale === 'ru') { + return { + title: 'Эрудит (Скрэббл)', + rulesUrl: 'https://ru.wikipedia.org/wiki/Скрэббл', + rulesPrefix: 'Основные ', + rulesLink: 'правила игры', + randomTitle: 'Случайная игра', + randomRespect: 'Уважайте личное время соперника, будьте терпеливы.', + random: [ + 'В игре двое соперников.', + 'Каждому доступна 1 подсказка в новой партии.', + `Лимит времени на ход: ${hours} ч. 00 минут.`, + 'Время отсутствия задаётся в профиле и продлевает лимит.', + ], + friendsTitle: 'Игра с друзьями', + friends: ['До 4-х участников.', 'Количество подсказок регулируется.', 'Произвольный лимит времени.'], + }; + } + return { + title: 'Scrabble', + rulesUrl: 'https://en.wikipedia.org/wiki/Scrabble', + rulesPrefix: 'Basic ', + rulesLink: 'game rules', + randomTitle: 'Random game', + randomRespect: "Respect your opponent's time, be patient.", + random: [ + 'Two opponents per game.', + 'Each player gets 1 hint per new game.', + `Move time limit: ${hours} h 00 min.`, + 'An away window set in your profile extends the limit.', + ], + friendsTitle: 'Game with friends', + friends: ['Up to 4 players.', 'The number of hints is configurable.', 'A custom time limit.'], + }; +} diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index 874f0da..786fb91 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -1,14 +1,37 @@
-

{t('app.title')}

-

{t('about.description')}

+

{c.title}

+

+ {c.rulesPrefix}{c.rulesLink}. +

+ +
+

{c.randomTitle}

+

❗️{c.randomRespect}

+
    + {#each c.random as item (item)}
  • {item}
  • {/each} +
+
+ +
+

{c.friendsTitle}

+
    + {#each c.friends as item (item)}
  • {item}
  • {/each} +
+
+

{t('about.version', { v: version })}

@@ -16,8 +39,41 @@ diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index 78ed9ca..02e18e8 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -9,3 +9,6 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +/** App version string, injected by Vite's define from `git describe` at build time. */ +declare const __APP_VERSION__: string; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 58e463f..b15496d 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -12,6 +12,12 @@ export default defineConfig(({ mode }) => ({ // Relative asset base so the one build serves under any path — the gateway maps the // Telegram Mini App to /telegram/ (the hash router is path-agnostic). base: './', + define: { + // App version shown on the About screen, injected at build time from `git describe` + // via a Docker build-arg (Stage 17). Falls back to "dev" for a plain local/mock build, + // so a missing build-arg never breaks the build. + __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), + }, plugins: [svelte()], server: { port: 5173, From d3657fdf5c402456365570a0ae6886d8a667a84a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 11:48:19 +0200 Subject: [PATCH 24/28] Stage 17 round 6 (#11/#12): quick-game variant plaques with rules, flag, and move-limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each auto-match variant is now a lobby-style plaque: the display name with a flag on the right (🇺🇸 / 🇷🇺; Erudit uses a bundled minimalist USSR flag SVG) and a one-line rules summary below — bag size, the ё rule, and bonus differences, sourced from the engine rulesets (Scrabble 100 · Скрэббл 104, ё a letter · Эрудит 131, ё=е, no centre ×2, +15). The move-time limit (24h auto-match clock) is shown under the buttons. e2e locks it. (Multiple-words-per-move is the same for every variant, so it is described in About/landing rather than repeated on each button.) --- ui/e2e/game.spec.ts | 8 +++++ ui/public/flag-ussr.svg | 11 +++++++ ui/src/lib/i18n/en.ts | 4 +++ ui/src/lib/i18n/ru.ts | 4 +++ ui/src/lib/variants.ts | 16 ++++++++++ ui/src/screens/NewGame.svelte | 55 ++++++++++++++++++++++++++++++++--- 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 ui/public/flag-ussr.svg diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 0c96883..f6e77e4 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -29,6 +29,14 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page }) await expect(page.locator('.make')).toBeHidden(); }); +test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match + await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary + await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons +}); + test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg new file mode 100644 index 0000000..90b07b7 --- /dev/null +++ b/ui/public/flag-ussr.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 03189ce..3a17859 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -45,6 +45,10 @@ export const en = { 'new.erudit': 'Erudite', 'new.find': 'Find a game', 'new.searching': 'Looking for an opponent…', + 'new.rulesEnglish': '100 tiles · bingo +50', + 'new.rulesRussian': '104 tiles · ё is a letter · bingo +50', + 'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15', + 'new.moveLimit': 'Move time: {n} h 00 min', 'game.bag': '{n} in the bag', 'game.bagEmpty': 'Bag is empty', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c7cec61..b8a2614 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -46,6 +46,10 @@ export const ru: Record = { 'new.erudit': 'Эрудит', 'new.find': 'Найти игру', 'new.searching': 'Ищем соперника…', + 'new.rulesEnglish': '100 фишек · бинго +50', + 'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50', + 'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15', + 'new.moveLimit': 'Время на ход: {n} ч. 00 мин.', 'game.bag': '{n} в мешке', 'game.bagEmpty': 'Мешок пуст', diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index a622c2b..62e3144 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -28,6 +28,22 @@ export function variantNameKey(v: Variant): MessageKey { return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english'; } +// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game +// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets. +export const VARIANT_RULES: Record = { + english: 'new.rulesEnglish', + russian_scrabble: 'new.rulesRussian', + erudit: 'new.rulesErudit', +}; + +// VARIANT_FLAG is the flag shown on a variant button: an emoji for the Scrabble variants; +// Erudit uses the bundled USSR flag SVG (public/flag-ussr.svg), so its entry is empty. +export const VARIANT_FLAG: Record = { + english: '🇺🇸', + russian_scrabble: '🇷🇺', + erudit: '', +}; + // VARIANT_LANGUAGE maps each variant to its game language. en -> English; // ru -> Russian + Эрудит. export const VARIANT_LANGUAGE: Record = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' }; diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 0b64511..caa27ee 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -6,7 +6,10 @@ import { navigate } from '../lib/router.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import type { AccountRef, Variant } from '../lib/model'; - import { availableVariants } from '../lib/variants'; + import { availableVariants, VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; + + // The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h). + const AUTO_MATCH_HOURS = 24; // The offered variants are gated by the languages the sign-in service supports // (Stage 15); the auto-match list and the friend-invite picker both use this. @@ -141,9 +144,20 @@

{t('new.subtitle')}

{#each variants as v (v.id)} - + {/each}
+

{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}

{:else if friends.length === 0}

{t('new.noFriends')}

{:else} @@ -207,15 +221,48 @@ flex-direction: column; gap: 10px; } + /* A plaque per variant (like the lobby game cards): the name with its flag on the right, + and a one-line rules summary below. */ .variant { - padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: var(--radius); + text-align: left; + user-select: none; + } + .vmain { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .vname { font-size: 1.05rem; font-weight: 600; - user-select: none; + } + .vflag { + font-size: 1.3rem; + line-height: 1; + } + .vflag-img { + width: 1.6rem; + height: auto; + border-radius: 2px; + } + .vrules { + font-size: 0.8rem; + color: var(--text-muted); + } + .movelimit { + margin: 0; + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; } .seg { display: flex; From 35666e17052fb5dc763329f9081bd6d6578058d8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 12:10:52 +0200 Subject: [PATCH 25/28] Stage 17 round 6 fixes: pin the nudge button right; schematic USSR flag emblem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chat: always render the (possibly empty) flex:1 caption before the nudge button, so the nudge stays pinned right whether or not the cooldown text shows (it drifted left when available). - USSR flag: redraw the hammer & sickle as a thin schematic sketch — an elongated semicircle sickle with a handle, crossed by a T-shaped hammer (per the original's structure), instead of the bold over-filled emblem; the star is a touch smaller. --- ui/public/flag-ussr.svg | 17 ++++++++++------- ui/src/game/Chat.svelte | 3 ++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg index 90b07b7..0bdc2ea 100644 --- a/ui/public/flag-ussr.svg +++ b/ui/public/flag-ussr.svg @@ -1,11 +1,14 @@ - - - - - - - + + + + + + + + + + diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index fb1ddb9..039d22d 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -57,7 +57,8 @@ /> {:else} - {#if nudgeOnCooldown}{t('chat.awaitingReply')}{/if} + + {nudgeOnCooldown ? t('chat.awaitingReply') : ''} {/if} From 2b0b1c0035335100501ab7d237fbc7b29b2815f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 12:21:09 +0200 Subject: [PATCH 26/28] Stage 17 round 6 (#3): drag-reorder rack tiles with a visual gap Dragging a rack tile and dropping it back on the rack reorders it: the dragged tile is lifted out (the drag ghost stands in) and the tiles at/after the pointer's drop slot slide right to open a gap there, so the drop position is visible. On drop the rack and its stable ids are permuted (reorderIndices, unit-tested). Reorder applies only with no pending tiles, so it stays a clean permutation; dropping on a board cell still places as before. Server persistence of the order follows (#4). --- ui/src/game/Game.svelte | 67 +++++++++++++++++++++++++++++++++--- ui/src/game/Rack.svelte | 23 +++++++++++-- ui/src/lib/placement.test.ts | 10 ++++++ ui/src/lib/placement.ts | 13 +++++++ 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index beafaac..36db4ff 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -28,6 +28,7 @@ placementFromHint, rackView, recallAt, + reorderIndices, reset, toSubmit, type Placement, @@ -193,6 +194,11 @@ // The empty board cell the dragged tile is currently aimed at, highlighted as a drop // target while carrying a tile over the board (Stage 17). Null over an occupied cell. let dropTarget = $state<{ row: number; col: number } | null>(null); + // Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id + // (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack + // (a gap opens there). Only when no tiles are pending, so the order is a clean permutation. + let reorderDragId = $state(null); + let reorderTo = $state(null); let dragPointerId = -1; function beginDrag(src: DragSrc, e: PointerEvent) { @@ -214,6 +220,7 @@ window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); clearHover(); + clearReorder(); downInfo = null; dragMoved = false; drag = null; @@ -241,6 +248,33 @@ hoverKey = ''; dropTarget = null; } + function clearReorder() { + reorderDragId = null; + reorderTo = null; + } + // overRack reports whether y is within the rack's row (a small margin makes the target + // forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles. + function overRack(y: number): boolean { + const r = (document.querySelector('[data-rack]') as HTMLElement | null)?.getBoundingClientRect(); + return !!r && y >= r.top - 24 && y <= r.bottom + 24; + } + function dropSlotAt(x: number): number { + const tiles = Array.from(document.querySelectorAll('[data-rack] .tile')) as HTMLElement[]; + for (let i = 0; i < tiles.length; i++) { + const r = tiles[i].getBoundingClientRect(); + if (x < r.left + r.width / 2) return i; + } + return tiles.length; + } + // reorderRack moves the rack tile at fromIndex to the drop slot, permuting the rack and + // its stable ids. Only valid with no pending tiles (the rack is then a clean permutation). + function reorderRack(fromIndex: number, toSlot: number) { + if (placement.pending.length > 0) return; + const order = reorderIndices(placement.rack.length, fromIndex, toSlot); + rackIds = order.map((i) => rackIds[i] ?? i); + placement = newPlacement(order.map((i) => placement.rack[i])); + selected = null; + } function onWinMove(e: PointerEvent) { if (!downInfo) return; if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) { @@ -249,15 +283,26 @@ const letter = src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? ''; drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY }; + // A rack tile is lifted out of the rack while dragged (the ghost stands in for it). + reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null; // No zoom on drag start: the player may still change their mind. Holding the tile // over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres. } if (!drag) return; drag = { ...drag, x: e.clientX, y: e.clientY }; const c = cellUnder(e.clientX, e.clientY); - // Highlight the aimed-at cell as a drop target, but only when it is free (no committed - // or pending tile there). - dropTarget = c && !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null; + // Preview where the drop lands: a drop-target ring on a free board cell, or — for a + // rack-source drag over the rack with no pending tiles — a reorder gap at that slot. + if (c) { + dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null; + reorderTo = null; + } else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) { + reorderTo = dropSlotAt(e.clientX); + dropTarget = null; + } else { + dropTarget = null; + reorderTo = null; + } const ck = c ? `${c.row},${c.col}` : ''; if (ck !== hoverKey) { hoverKey = ck; @@ -287,8 +332,12 @@ drag = null; const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]'); const cell = cellUnder(e.clientX, e.clientY); + const to = reorderTo; if (di.src.from === 'rack' && cell) { attemptPlace(di.src.index, cell.row, cell.col); + } else if (di.src.from === 'rack' && onRack && to != null) { + // Dropped a rack tile back onto the rack → reorder it to the drop slot. + reorderRack(di.src.index, to); } else if (di.src.from === 'board' && onRack) { // Dropped a pending tile back onto the rack → recall it to its original slot. placement = recallAt(placement, di.src.row, di.src.col); @@ -302,12 +351,14 @@ } else { drag = null; } + clearReorder(); } onDestroy(() => { window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); clearHover(); + clearReorder(); telegramClosingConfirmation(false); }); @@ -667,7 +718,15 @@ a finished game shows the final rack greyed out and the controls disabled. -->
- +
{#if !gameOver && placement.pending.length > 0} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 0a50e1b..6a11f3b 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -9,6 +9,8 @@ variant, selected, shuffling = false, + draggingId = null, + dropIndex = null, ondown, }: { // Each slot carries a stable id that travels with its tile through a shuffle, so the @@ -17,12 +19,18 @@ variant: Variant; selected: number | null; shuffling?: boolean; + // While a rack tile is being dragged to reorder it, draggingId is its id (hidden here — + // the drag ghost stands in) and dropIndex is the slot where a gap opens (Stage 17). + draggingId?: number | null; + dropIndex?: number | null; ondown: (e: PointerEvent, index: number) => void; } = $props(); // Used slots are hidden (the rack shifts left, freeing room on the right for the - // MakeMove control); the slot still exists in the model for per-tile recall. + // MakeMove control); the slot still exists in the model for per-tile recall. While + // reordering, the dragged tile is lifted out (the ghost shows it). const visible = $derived(slots.filter((s) => !s.used)); + const shown = $derived(draggingId == null ? visible : visible.filter((s) => s.id !== draggingId)); // hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile // height). The duration scales with the horizontal distance — i.e. the arc length — so @@ -44,11 +52,12 @@ } -
- {#each visible as slot (slot.id)} +
+ {#each shown as slot, i (slot.id)}