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)}
{: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 @@
- {#if back}
+ {#if showBack}
diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte
index acd189d..7fa7379 100644
--- a/ui/src/components/Modal.svelte
+++ b/ui/src/components/Modal.svelte
@@ -4,18 +4,21 @@
let {
title = '',
onclose,
+ overlayKeyboard = false,
children,
- }: { title?: string; onclose?: () => void; children?: Snippet } = $props();
+ }: { title?: string; onclose?: () => void; overlayKeyboard?: boolean; children?: Snippet } = $props();
// Track the visual viewport so the backdrop covers only the area above an open
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
// backdrop still centres it behind the keyboard. Sizing the backdrop to
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
+ // overlayKeyboard opts out: the sheet is small and top-anchored, so the keyboard
+ // simply overlays the empty lower area — no resize, no relayout jank (e.g. check word).
let vh = $state(0);
let top = $state(0);
$effect(() => {
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
- if (!vv) return;
+ if (!vv || overlayKeyboard) return;
const update = () => {
vh = vv.height;
top = vv.offsetTop;
@@ -34,6 +37,7 @@
onclose?.()}
@@ -61,6 +65,12 @@
padding: 16px;
z-index: 40;
}
+ /* Overlay mode: top-anchor the (small) sheet and don't track the keyboard, so the
+ soft keyboard overlays the empty lower area without resizing/relaying out. */
+ .backdrop.overlay {
+ align-items: flex-start;
+ padding-top: 12vh;
+ }
.sheet {
background: var(--surface);
color: var(--text);
diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte
index 12a2b93..6ebe39b 100644
--- a/ui/src/components/Screen.svelte
+++ b/ui/src/components/Screen.svelte
@@ -14,6 +14,7 @@
children,
scroll = true,
growNav = false,
+ column = false,
}: {
title: string;
back?: string;
@@ -22,13 +23,16 @@
children?: Snippet;
scroll?: boolean;
growNav?: boolean;
+ // column lays the content out as a flex column so a child can own the vertical fit
+ // (the game makes only its board scroll while the score/rack/tab bar stay put).
+ column?: boolean;
} = $props();
- {@render children?.()}
+ {@render children?.()}
{#if tabbar}
{/if}
@@ -50,6 +54,10 @@
.content.scroll {
overflow-y: auto;
}
+ .content.column {
+ display: flex;
+ flex-direction: column;
+ }
.tabbar {
flex: 0 0 auto;
}
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index bc258cc..5d1272e 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -19,6 +19,7 @@
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
+ import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
BLANK,
newPlacement,
@@ -112,6 +113,8 @@
}
}
onMount(() => {
+ // Guard against an accidental swipe-close losing the open game (Telegram).
+ telegramClosingConfirmation(true);
// 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);
@@ -185,6 +188,7 @@
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
+ telegramClosingConfirmation(false);
});
function onCell(row: number, col: number) {
@@ -212,12 +216,14 @@
return;
}
placement = place(placement, index, row, col);
+ telegramHaptic('select');
recompute();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
blankPrompt = null;
+ telegramHaptic('select');
recompute();
}
@@ -242,6 +248,7 @@
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
+ telegramHaptic('success');
zoomed = false;
await load();
} catch (e) {
@@ -449,7 +456,7 @@
]);
-
+
{#snippet menu()}
{/snippet}
@@ -596,7 +603,7 @@
{/if}
{#if checkOpen}
- (checkOpen = false)}>
+ (checkOpen = false)}>
.scoreboard {
display: flex;
+ flex: none;
gap: 6px;
padding: 8px var(--pad);
background: var(--bg-elev);
@@ -681,7 +689,12 @@
}
.stage {
position: relative;
- overflow: hidden;
+ /* The board is the only part that scrolls vertically when the game does not fit;
+ the score bar, status, rack and tab bar stay put (#9). */
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
}
.history {
position: absolute;
@@ -741,6 +754,7 @@
}
.status {
display: flex;
+ flex: none;
align-items: center;
justify-content: space-between;
padding: 2px var(--pad) 6px;
@@ -762,6 +776,7 @@
}
.rack-row {
display: flex;
+ flex: none;
gap: 8px;
align-items: stretch;
padding: 0 var(--pad) 6px;
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index e001ba9..0af18fa 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -9,7 +9,16 @@ 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, telegramOnEvent } from './telegram';
+import {
+ insideTelegram,
+ onTelegramPath,
+ telegramColorScheme,
+ telegramDisableVerticalSwipes,
+ telegramHaptic,
+ telegramLaunch,
+ telegramOnEvent,
+ telegramSetChrome,
+} from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
@@ -93,6 +102,7 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
export function handleError(err: unknown): void {
+ telegramHaptic('error');
if (err instanceof GatewayError) {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
void logout();
@@ -200,6 +210,20 @@ export async function applyLinkResult(r: LinkResult): Promise {
app.profile = await gateway.profileGet();
}
+/**
+ * syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
+ * theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
+ */
+function syncTelegramChrome(): void {
+ if (typeof document === 'undefined') return;
+ const cs = getComputedStyle(document.documentElement);
+ telegramSetChrome(
+ cs.getPropertyValue('--bg-elev').trim(),
+ cs.getPropertyValue('--bg').trim(),
+ cs.getPropertyValue('--bg-elev').trim(),
+ );
+}
+
export async function bootstrap(): Promise {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
@@ -232,6 +256,10 @@ export async function bootstrap(): Promise {
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
+ // Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
+ // fighting tile drag / board scroll.
+ syncTelegramChrome();
+ telegramDisableVerticalSwipes();
try {
await adoptSession(await gateway.authTelegram(launch.initData));
await routeStartParam(launch.startParam);
diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts
index 5440086..1ed3a63 100644
--- a/ui/src/lib/telegram.ts
+++ b/ui/src/lib/telegram.ts
@@ -13,6 +13,23 @@ interface TelegramWebApp {
ready?: () => void;
expand?: () => void;
onEvent?: (event: string, handler: () => void) => void;
+ setHeaderColor?: (color: string) => void;
+ setBackgroundColor?: (color: string) => void;
+ setBottomBarColor?: (color: string) => void;
+ disableVerticalSwipes?: () => void;
+ enableClosingConfirmation?: () => void;
+ disableClosingConfirmation?: () => void;
+ HapticFeedback?: {
+ impactOccurred?: (style: string) => void;
+ notificationOccurred?: (type: string) => void;
+ selectionChanged?: () => void;
+ };
+ BackButton?: {
+ show?: () => void;
+ hide?: () => void;
+ onClick?: (cb: () => void) => void;
+ offClick?: (cb: () => void) => void;
+ };
}
function webApp(): TelegramWebApp | undefined {
@@ -70,6 +87,71 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
return webApp()?.colorScheme;
}
+/**
+ * telegramSetChrome paints Telegram's own header, background and bottom bar to match the
+ * app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
+ * outside Telegram or on a client predating a given setter.
+ */
+export function telegramSetChrome(header: string, background: string, bottom: string): void {
+ const w = webApp();
+ if (header) w?.setHeaderColor?.(header);
+ if (background) w?.setBackgroundColor?.(background);
+ if (bottom) w?.setBottomBarColor?.(bottom);
+}
+
+/**
+ * telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
+ * it does not fight tile drag-and-drop or the board's vertical scroll.
+ */
+export function telegramDisableVerticalSwipes(): void {
+ webApp()?.disableVerticalSwipes?.();
+}
+
+/** Haptic is the set of feedbacks the app triggers. */
+export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
+
+/** telegramHaptic fires a Telegram haptic; a no-op outside Telegram or on older clients. */
+export function telegramHaptic(kind: Haptic): void {
+ const h = webApp()?.HapticFeedback;
+ if (!h) return;
+ if (kind === 'select') h.selectionChanged?.();
+ else if (kind === 'success' || kind === 'error' || kind === 'warning') h.notificationOccurred?.(kind);
+ else h.impactOccurred?.(kind);
+}
+
+/**
+ * telegramClosingConfirmation toggles the confirmation Telegram shows when the user
+ * swipes the Mini App closed — enabled during an active game so it is not lost by accident.
+ */
+export function telegramClosingConfirmation(on: boolean): void {
+ const w = webApp();
+ if (on) w?.enableClosingConfirmation?.();
+ else w?.disableClosingConfirmation?.();
+}
+
+let backHandler: (() => void) | null = null;
+
+/**
+ * telegramBackButton shows or hides Telegram's native header back button, wiring its
+ * click to onClick (replacing any previous handler). The app hides its own back chevron
+ * inside Telegram so only the native control shows.
+ */
+export function telegramBackButton(show: boolean, onClick?: () => void): void {
+ const b = webApp()?.BackButton;
+ if (!b) return;
+ if (backHandler) b.offClick?.(backHandler);
+ backHandler = null;
+ if (show) {
+ if (onClick) {
+ backHandler = onClick;
+ b.onClick?.(onClick);
+ }
+ b.show?.();
+ } else {
+ b.hide?.();
+ }
+}
+
/**
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe.
From b15fd30c4fb378623119353c50bf2a6b468639c9 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sat, 6 Jun 2026 14:08:40 +0200
Subject: [PATCH 08/28] Stage 17 (contour round 4a): quick fixes
- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
---
backend/internal/account/profile.go | 7 +++--
backend/internal/account/validate_test.go | 28 ++++++++++---------
.../templates/pages/game_detail.gohtml | 4 +--
ui/src/components/Toast.svelte | 13 ++++++++-
ui/src/game/Board.svelte | 9 ++++--
ui/src/game/Game.svelte | 6 ++--
ui/src/lib/i18n.test.ts | 4 +--
ui/src/lib/i18n/en.ts | 3 +-
ui/src/lib/i18n/ru.ts | 3 +-
ui/src/lib/profileValidation.test.ts | 5 +++-
ui/src/lib/profileValidation.ts | 7 +++--
ui/src/screens/Profile.svelte | 4 ++-
12 files changed, 61 insertions(+), 32 deletions(-)
diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go
index daf78b5..8aa7985 100644
--- a/backend/internal/account/profile.go
+++ b/backend/internal/account/profile.go
@@ -28,9 +28,10 @@ const maxAwayWindow = 12 * time.Hour
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
-// by a single space. No leading or trailing separator and no two adjacent separators,
-// except "". So "Name_P. Last" is valid, "Name P._Last" is not.
-var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
+// by a single space. No leading separator and no two adjacent separators (except
+// ""); a single trailing "." is allowed (Stage 17), so
+// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
+var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
// ErrInvalidProfile is returned when a profile update carries an unacceptable
// field (an unknown language, an invalid timezone, or an over-long display name).
diff --git a/backend/internal/account/validate_test.go b/backend/internal/account/validate_test.go
index 1e8c978..0bdee79 100644
--- a/backend/internal/account/validate_test.go
+++ b/backend/internal/account/validate_test.go
@@ -12,19 +12,21 @@ func TestValidateDisplayName(t *testing.T) {
want string
ok bool
}{
- "plain": {"Kaya", "Kaya", true},
- "cyrillic": {"Кая", "Кая", true},
- "dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
- "single dot": {"Mr.Smith", "Mr.Smith", true},
- "dot then space": {"Mr. Smith", "Mr. Smith", true},
- "trim surrounding": {" Kaya ", "Kaya", true},
- "adjacent specials": {"Name P._Last", "", false},
- "two spaces": {"Name Last", "", false},
- "leading special": {"_Name", "", false},
- "trailing special": {"Name.", "", false},
- "digit rejected": {"Name2", "", false},
- "blank": {" ", "", false},
- "too long": {strings.Repeat("a", 33), "", false},
+ "plain": {"Kaya", "Kaya", true},
+ "cyrillic": {"Кая", "Кая", true},
+ "dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
+ "single dot": {"Mr.Smith", "Mr.Smith", true},
+ "dot then space": {"Mr. Smith", "Mr. Smith", true},
+ "trim surrounding": {" Kaya ", "Kaya", true},
+ "adjacent specials": {"Name P._Last", "", false},
+ "two spaces": {"Name Last", "", false},
+ "leading special": {"_Name", "", false},
+ "trailing underscore": {"Name_", "", false},
+ "trailing dot ok": {"Anna B.", "Anna B.", true},
+ "double trailing dot": {"Name..", "", false},
+ "digit rejected": {"Name2", "", false},
+ "blank": {" ", "", false},
+ "too long": {strings.Repeat("a", 33), "", false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
index 313b31f..436fbb2 100644
--- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
@@ -17,10 +17,10 @@
{/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 .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}} next move {{.NextMove}}{{end}}{{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}
@@ -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 @@
@@ -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 @@
}
-