feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s

The statistics screen gains real depth, plus a hint-count bug fix found along the way.

- Best move per variant: the screen shows the actual best-move word (drawn as game
  tiles; a wildcard shows its letter but no value), broken down by game variant, empty
  variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
  plays that used a hint — from summed account_stats counters (moves, hints_used).
  Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
  wallet now rides the wire apart from the per-game allowance (wallet_balance on
  StateView/HintResult/StatsView), so the client reads the live wallet rather than a
  per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
  its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
  moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
  changes.

All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
This commit was merged in pull request #81.
This commit is contained in:
2026-06-17 22:17:27 +00:00
parent 5a3f0951ae
commit 8793bd34f2
71 changed files with 1789 additions and 132 deletions
+1
View File
@@ -74,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
+85 -13
View File
@@ -1027,12 +1027,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
}
walletAfter := acc.HintBalance
if fromAllowance {
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
} else {
if !fromAllowance {
spent, err := svc.accounts.SpendHint(ctx, accountID)
if err != nil {
return HintResult{}, err
@@ -1042,7 +1037,14 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
}
walletAfter--
}
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
// hints_used is the per-game total (allowance + wallet): every hint increments it. The first
// HintsPerPlayer hints are the free allowance (so fromAllowance above stays correct); the rest
// are charged to the wallet. Counting all hints feeds the player's lifetime hint statistics.
if err := svc.store.IncHintsUsed(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
}
// Candidates returns the to-move player's legal plays for a seated player on
@@ -1130,6 +1132,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
Rack: g.Hand(seat),
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
WalletBalance: acc.HintBalance,
}, nil
}
@@ -1419,19 +1422,45 @@ 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)
plays := make(map[int]int) // per player: count of plays (tile placements), for the "moves" stat
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
}
plays[rec.Player]++
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]}
// moves counts the seat's plays; hintsUsed is the seat's total hints this game. Both are
// summed into account_stats so the screen can show the hint share (hints_used / moves).
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), moves: plays[s.Seat], hintsUsed: s.HintsUsed}
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 +1474,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.
+50
View File
@@ -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)
}
}
+63 -9
View File
@@ -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,22 @@ 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
moves int // plays this game (tile placements), summed into account_stats.moves
hintsUsed int // hints used this game (allowance + wallet), summed into account_stats.hints_used
bestVariant string
bestScore int
bestTiles []account.BestMoveTile
}
// commit is everything a single committed transition persists: the journal row,
@@ -633,6 +644,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 +676,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
})
@@ -714,13 +731,17 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
draws := row.Draws + int32(d.draws)
maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
moves := row.Moves + int32(d.moves)
hintsUsed := row.HintsUsed + int32(d.hintsUsed)
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
table.AccountStats.Moves, table.AccountStats.HintsUsed,
).SET(
postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)),
postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now),
postgres.Int(int64(moves)), postgres.Int(int64(hintsUsed)),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("update stats %s: %w", d.accountID, err)
@@ -728,8 +749,41 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
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 {
// 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
}
// IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for
// every hint — both the free per-game allowance and the wallet-charged ones — so the counter
// is the seat's total hints used this game (the first HintsPerPlayer being the allowance).
func (s *Store) IncHintsUsed(ctx context.Context, gameID uuid.UUID, seat int) error {
stmt := table.GamePlayers.
UPDATE(table.GamePlayers.HintsUsed).
SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
@@ -738,7 +792,7 @@ func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat i
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("game: spend hint allowance: %w", err)
return fmt.Errorf("game: increment hints used: %w", err)
}
return nil
}
+9 -1
View File
@@ -154,6 +154,8 @@ type Seat struct {
Seat int
AccountID uuid.UUID
Score int
// HintsUsed is the total hints the seat used this game — both the free per-game allowance
// and the wallet-charged ones (the first HintsPerPlayer being the allowance).
HintsUsed int
IsWinner bool
// DisplayName is the seat's display-name snapshot, captured when the seat was taken
@@ -194,10 +196,13 @@ type MoveResult struct {
}
// HintResult is a revealed hint and the requesting player's remaining hint
// budget (per-seat allowance plus profile wallet) after spending one.
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
// the global wallet alone, so the client can keep its live wallet authoritative and
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
type HintResult struct {
Move engine.MoveRecord
HintsRemaining int
WalletBalance int
}
// EvalResult previews a tentative play without committing it. Dir is the
@@ -220,6 +225,9 @@ type StateView struct {
Rack []string
BagLen int
HintsRemaining int
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
// it in with the per-game allowance), so the client keeps the wallet live across games.
WalletBalance int
}
// HistoryMove is one decoded journal row, independent of any dictionary.