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
+71 -6
View File
@@ -1419,19 +1419,41 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
}
// buildStats derives each seat's statistics contribution from a finished game:
// win/loss/draw from the (resignation-aware) winner, the final score, and the
// best single-move score from the log.
// win/loss/draw from the (resignation-aware) winner, the final score, and the best
// single play from the log — its score and, for the per-variant breakdown, its main
// word as rendering tiles. Blank flags are taken from every blank ever placed (so a
// blank laid by an earlier move and embedded in the best word is honoured), which is
// equivalent to reading the final board since a placed tile never moves.
func buildStats(g *engine.Game, seats []Seat) []statDelta {
res := g.Result()
best := make(map[int]int)
bestRec := make(map[int]engine.MoveRecord)
blanks := make(map[[2]int]bool)
for _, rec := range g.Log() {
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
best[rec.Player] = rec.Score
if rec.Action != engine.ActionPlay {
continue
}
for _, t := range rec.Tiles {
if t.Blank {
blanks[[2]int{t.Row, t.Col}] = true
}
}
if cur, ok := bestRec[rec.Player]; !ok || rec.Score > cur.Score {
bestRec[rec.Player] = rec
}
}
variant := g.Variant().String()
values := letterValues(g.Variant())
out := make([]statDelta, 0, len(seats))
for _, s := range seats {
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat)}
if rec, ok := bestRec[s.Seat]; ok {
d.wordPoints = rec.Score
if rec.Score > 0 {
d.bestVariant = variant
d.bestScore = rec.Score
d.bestTiles = mainWordTiles(rec, blanks, values)
}
}
switch {
case res.Winner < 0:
d.draws = 1
@@ -1445,6 +1467,49 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
return out
}
// letterValues builds a lower-cased letter -> tile value lookup for a variant from the
// engine's alphabet table, so a best-move word can be rendered with per-tile values on a
// screen (statistics) that has not cached the variant's alphabet. It is empty for an
// unrecognised variant, leaving every value zero.
func letterValues(v engine.Variant) map[string]int {
table, err := engine.AlphabetTable(v)
if err != nil {
return nil
}
m := make(map[string]int, len(table))
for _, e := range table {
m[strings.ToLower(e.Letter)] = e.Value
}
return m
}
// mainWordTiles decodes a play's main word into rendering tiles: each letter with its
// tile value (0 for a blank) and blank flag. blanks is the set of board coordinates a
// blank was ever placed on; values maps a lower-cased letter to its tile value. It walks
// the word from its first-letter coordinate along the play's orientation.
func mainWordTiles(rec engine.MoveRecord, blanks map[[2]int]bool, values map[string]int) []account.BestMoveTile {
if len(rec.Words) == 0 {
return nil
}
dr, dc := 0, 1
if rec.Dir == engine.Vertical {
dr, dc = 1, 0
}
letters := []rune(rec.Words[0])
out := make([]account.BestMoveTile, len(letters))
for i, r := range letters {
row, col := rec.MainRow+i*dr, rec.MainCol+i*dc
blank := blanks[[2]int{row, col}]
letter := string(r)
value := 0
if !blank {
value = values[strings.ToLower(letter)]
}
out[i] = account.BestMoveTile{Letter: letter, Value: value, Blank: blank}
}
return out
}
// nonGuestSeats filters out guest seats so the finish-time statistics are
// recomputed for durable non-guest accounts only — guests never accrue
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.