feat(stats): show the best move word per game variant
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s

Replace the single "best move" number on the statistics screen with a
full-width per-variant breakdown: the highest-scoring play in each variant
the player has played, drawn as game tiles (a wildcard shows its letter but
no value), with the words and scores right-aligned to shared edges.

- backend: new account_best_move table (PK account_id+variant) keeping the
  main word as JSON tiles {letter,value,blank}; captured at game finish in
  buildStats (blank flags taken from every placed blank — equivalent to the
  final board), upserted in the finish transaction and replaced only by a
  strictly higher-scoring play. Guest/honest-AI games still record nothing.
  GetStats + statsDTO expose best_moves.
- wire: StatsView gains best_moves:[BestMoveView{variant,score,word:[BestMoveTile]}]
  (trailing, backward-compatible); gateway encodeStats + UI codec updated.
- ui: new WordTiles component (board's tile look, fixed px size); Stats.svelte
  drops the maxWord card and adds the full-width best-move card (catalogue
  order, empty variants omitted).
- docs: ARCHITECTURE §9 + schema, FUNCTIONAL (+ru), UI_DESIGN.

Tests: mainWordTiles unit + buildStats end-to-end (inttest) + gateway and UI
codec round-trips (incl. a blank tile) + e2e.
This commit is contained in:
Ilia Denisov
2026-06-17 15:29:55 +02:00
parent 5a3f0951ae
commit cbb485ebd6
33 changed files with 1217 additions and 44 deletions
+2 -1
View File
@@ -89,7 +89,8 @@ func TestGetStatsZeroForFreshAccount(t *testing.T) {
if err != nil {
t.Fatalf("get stats: %v", err)
}
if (st != account.Stats{}) {
zero := st.Wins == 0 && st.Losses == 0 && st.Draws == 0 && st.MaxGamePoints == 0 && st.MaxWordPoints == 0
if !zero || len(st.BestMoves) != 0 {
t.Fatalf("fresh stats = %+v, want zero", st)
}
}
+41 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
@@ -120,8 +121,8 @@ func TestGameLifecycleAndStats(t *testing.T) {
t.Fatalf("final game not finished: %+v", last.Game)
}
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
w0, l0, d0, mg0, mw0, ok0 := readStats(t, seats[0])
w1, l1, d1, mg1, mw1, ok1 := readStats(t, seats[1])
if !ok0 || !ok1 {
t.Fatal("both players must have a stats row")
}
@@ -133,6 +134,44 @@ func TestGameLifecycleAndStats(t *testing.T) {
if !decisive && !draw {
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
}
// Each player who made a scoring play gets exactly one per-variant best move (only
// scrabble_en was played here); its score equals the aggregate max_word_points and it
// carries the decoded word as tiles.
accounts := account.NewStore(testDB)
for _, p := range []struct {
id uuid.UUID
maxWord int
}{{seats[0], mw0}, {seats[1], mw1}} {
if p.maxWord == 0 {
continue // a player who only passed has no best move
}
st, err := accounts.GetStats(ctx, p.id)
if err != nil {
t.Fatalf("get stats: %v", err)
}
if len(st.BestMoves) != 1 {
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
}
bm := st.BestMoves[0]
if bm.Variant != engine.VariantEnglish.String() {
t.Errorf("best move variant = %q, want %q", bm.Variant, engine.VariantEnglish.String())
}
if bm.Score != p.maxWord {
t.Errorf("best move score %d != max_word_points %d", bm.Score, p.maxWord)
}
if len(bm.Tiles) == 0 {
t.Error("best move word is empty")
}
for _, tl := range bm.Tiles {
if tl.Letter == "" {
t.Error("best move tile has empty letter")
}
if tl.Blank && tl.Value != 0 {
t.Errorf("blank tile must score 0, got %d", tl.Value)
}
}
}
}
// TestReplayEquivalence plays a few moves through one service, then proves a