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
+48 -2
View File
@@ -91,15 +91,61 @@ func encodeRedeemResult(r backendclient.RedeemResultResp) []byte {
return b.FinishedBytes()
}
// encodeStats builds a StatsView payload.
// buildBestMoveTile builds a BestMoveTile table and returns its offset.
func buildBestMoveTile(b *flatbuffers.Builder, t backendclient.BestMoveTileResp) flatbuffers.UOffsetT {
letter := b.CreateString(t.Letter)
fb.BestMoveTileStart(b)
fb.BestMoveTileAddLetter(b, letter)
fb.BestMoveTileAddValue(b, int32(t.Value))
fb.BestMoveTileAddBlank(b, t.Blank)
return fb.BestMoveTileEnd(b)
}
// buildBestMove builds a BestMoveView table — its word tile vector first, per the
// bottom-up rule — and returns its offset.
func buildBestMove(b *flatbuffers.Builder, m backendclient.BestMoveResp) flatbuffers.UOffsetT {
tiles := make([]flatbuffers.UOffsetT, len(m.Word))
for i, t := range m.Word {
tiles[i] = buildBestMoveTile(b, t)
}
fb.BestMoveViewStartWordVector(b, len(tiles))
for i := len(tiles) - 1; i >= 0; i-- {
b.PrependUOffsetT(tiles[i])
}
word := b.EndVector(len(tiles))
variant := b.CreateString(m.Variant)
fb.BestMoveViewStart(b)
fb.BestMoveViewAddVariant(b, variant)
fb.BestMoveViewAddScore(b, int32(m.Score))
fb.BestMoveViewAddWord(b, word)
return fb.BestMoveViewEnd(b)
}
// encodeStats builds a StatsView payload, embedding the per-variant best moves (each a
// BestMoveView with its word tiles) when present.
func encodeStats(r backendclient.StatsResp) []byte {
b := flatbuffers.NewBuilder(64)
b := flatbuffers.NewBuilder(256)
var bestMoves flatbuffers.UOffsetT
if len(r.BestMoves) > 0 {
offs := make([]flatbuffers.UOffsetT, len(r.BestMoves))
for i, m := range r.BestMoves {
offs[i] = buildBestMove(b, m)
}
fb.StatsViewStartBestMovesVector(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
bestMoves = b.EndVector(len(offs))
}
fb.StatsViewStart(b)
fb.StatsViewAddWins(b, int32(r.Wins))
fb.StatsViewAddLosses(b, int32(r.Losses))
fb.StatsViewAddDraws(b, int32(r.Draws))
fb.StatsViewAddMaxGamePoints(b, int32(r.MaxGamePoints))
fb.StatsViewAddMaxWordPoints(b, int32(r.MaxWordPoints))
if len(r.BestMoves) > 0 {
fb.StatsViewAddBestMoves(b, bestMoves)
}
b.Finish(fb.StatsViewEnd(b))
return b.FinishedBytes()
}