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
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:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// TestMainWordTilesHorizontalWithBlank checks that a horizontal main word is decoded into
|
||||
// tiles along its row, that the per-letter value is looked up from the value table, and that
|
||||
// a blank cell (here the middle 'a') is rendered with a zero value regardless of its letter
|
||||
// value — the blank flag is taken from the placed-blank set, not from this move's own tiles.
|
||||
func TestMainWordTilesHorizontalWithBlank(t *testing.T) {
|
||||
rec := engine.MoveRecord{Dir: engine.Horizontal, MainRow: 7, MainCol: 5, Words: []string{"cat"}}
|
||||
blanks := map[[2]int]bool{{7, 6}: true}
|
||||
values := map[string]int{"c": 3, "a": 1, "t": 1}
|
||||
got := mainWordTiles(rec, blanks, values)
|
||||
want := []account.BestMoveTile{
|
||||
{Letter: "c", Value: 3, Blank: false},
|
||||
{Letter: "a", Value: 0, Blank: true},
|
||||
{Letter: "t", Value: 1, Blank: false},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mainWordTiles = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainWordTilesVerticalCyrillic checks vertical walk and multi-byte (Cyrillic) letters:
|
||||
// the word must be split by rune, not byte, and laid down its column.
|
||||
func TestMainWordTilesVerticalCyrillic(t *testing.T) {
|
||||
rec := engine.MoveRecord{Dir: engine.Vertical, MainRow: 3, MainCol: 8, Words: []string{"съёмка"}}
|
||||
values := map[string]int{"с": 1, "ъ": 10, "ё": 4, "м": 2, "к": 2, "а": 1}
|
||||
got := mainWordTiles(rec, map[[2]int]bool{}, values)
|
||||
want := []account.BestMoveTile{
|
||||
{Letter: "с", Value: 1}, {Letter: "ъ", Value: 10}, {Letter: "ё", Value: 4},
|
||||
{Letter: "м", Value: 2}, {Letter: "к", Value: 2}, {Letter: "а", Value: 1},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mainWordTiles = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainWordTilesNoWord checks the defensive nil for a record carrying no words.
|
||||
func TestMainWordTilesNoWord(t *testing.T) {
|
||||
if got := mainWordTiles(engine.MoveRecord{}, nil, nil); got != nil {
|
||||
t.Errorf("mainWordTiles(no words) = %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package game
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
@@ -53,13 +55,20 @@ type gameInsert struct {
|
||||
}
|
||||
|
||||
// statDelta is one account's contribution to its statistics on a game finish.
|
||||
// bestVariant/bestScore/bestTiles describe the game's best play for this account when
|
||||
// it scored (bestVariant empty otherwise): the variant label, the play's total score and
|
||||
// its main word as rendering tiles. They feed the per-variant account_best_move upsert,
|
||||
// which keeps only the account's highest-scoring play per variant.
|
||||
type statDelta struct {
|
||||
accountID uuid.UUID
|
||||
wins int
|
||||
losses int
|
||||
draws int
|
||||
gamePoints int
|
||||
wordPoints int
|
||||
accountID uuid.UUID
|
||||
wins int
|
||||
losses int
|
||||
draws int
|
||||
gamePoints int
|
||||
wordPoints int
|
||||
bestVariant string
|
||||
bestScore int
|
||||
bestTiles []account.BestMoveTile
|
||||
}
|
||||
|
||||
// commit is everything a single committed transition persists: the journal row,
|
||||
@@ -633,6 +642,9 @@ func (s *Store) CommitMove(ctx context.Context, c commit) error {
|
||||
if err := upsertStats(ctx, tx, d, c.now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertBestMove(ctx, tx, d, c.now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -662,6 +674,9 @@ func (s *Store) VoidGame(ctx context.Context, v voidCommit) error {
|
||||
if err := upsertStats(ctx, tx, d, v.now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertBestMove(ctx, tx, d, v.now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -728,6 +743,37 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertBestMove records the account's best play for a variant, keeping only the
|
||||
// highest-scoring one: a first play inserts, a later one replaces it only when it scored
|
||||
// strictly higher (the conditional DO UPDATE makes the upsert atomic under concurrent
|
||||
// finishes without a separate lock). It is a no-op when the finish carries no scoring play
|
||||
// for the account (a draw with no plays, or an exchange/pass-only game).
|
||||
func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
|
||||
if d.bestVariant == "" || len(d.bestTiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
tiles, err := json.Marshal(d.bestTiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal best move %s/%s: %w", d.accountID, d.bestVariant, err)
|
||||
}
|
||||
stmt := table.AccountBestMove.
|
||||
INSERT(
|
||||
table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
|
||||
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt,
|
||||
).
|
||||
VALUES(d.accountID, d.bestVariant, d.bestScore, string(tiles), postgres.TimestampzT(now)).
|
||||
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
|
||||
DO_UPDATE(postgres.SET(
|
||||
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
|
||||
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
|
||||
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
|
||||
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("upsert best move %s/%s: %w", d.accountID, d.bestVariant, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpendHintAllowance increments a seat's per-game hint counter by one.
|
||||
func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
|
||||
stmt := table.GamePlayers.
|
||||
|
||||
Reference in New Issue
Block a user