Files
scrabble-game/backend/internal/account/stats.go
T
Ilia Denisov 6d1d8030e3
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
feat(stats): per-game hints used + lifetime moves and hint share
Store the hints a player used in each game, and add two lifetime tiles —
Moves and Hint share — to the statistics screen.

- per-game: game_players.hints_used now counts EVERY hint (allowance + wallet),
  not just the free allowance, so it is the seat's true total hints used this
  game. The allowance decision (used < HintsPerPlayer) and the hint badge values
  (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does
  NOT regress; only the admin "hints used" column, which silently under-counted
  wallet hints, becomes accurate.
- account_stats gains two summed counters: moves (the player's plays — passes and
  exchanges excluded) and hints_used (every hint). Computed at game finish in
  buildStats over the same non-guest, non-honest-AI games as the rest of the stats.
- wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model;
  regen.
- ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active
  locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate.
- docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN.

Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats
(moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e.
2026-06-17 23:44:53 +02:00

116 lines
4.1 KiB
Go

package account
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// BestMoveTile is one letter cell of a best-move word: its concrete letter (the
// designated letter for a blank), its tile point value (0 for a blank) and whether it
// is a blank. It is the persisted/served shape: the game domain marshals a slice of
// these into account_best_move.tiles, and the statistics screen renders them as game
// tiles without consulting the variant's alphabet.
type BestMoveTile struct {
Letter string `json:"letter"`
Value int `json:"value"`
Blank bool `json:"blank"`
}
// BestMove is an account's highest-scoring single play within one game variant: the
// move's total score (every word it formed plus the all-tiles bonus, matching
// MaxWordPoints) and its main word as an ordered slice of tiles.
type BestMove struct {
Variant string
Score int
Tiles []BestMoveTile
}
// Stats is a durable account's lifetime record, written by the game domain on each
// finish and read for the player's statistics screen. MaxGamePoints is the best
// single game's total; MaxWordPoints is the best single move's score (which already
// includes every word it formed plus the all-tiles bonus). BestMoves holds the same
// best move broken down per variant, with the word itself — empty for an account with
// no recorded play yet, and never carrying a variant the account has not played.
type Stats struct {
Wins int
Losses int
Draws int
MaxGamePoints int
MaxWordPoints int
// Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the
// lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves).
Moves int
HintsUsed int
BestMoves []BestMove
}
// GetStats returns the lifetime statistics for id. An account with no account_stats
// row yet — a guest, or a player who has not finished a game — yields the zero
// Stats (all counters zero) rather than an error.
func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
stmt := postgres.SELECT(table.AccountStats.AllColumns).
FROM(table.AccountStats).
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.AccountStats
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Stats{}, nil
}
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
}
best, err := s.bestMoves(ctx, id)
if err != nil {
return Stats{}, err
}
return Stats{
Wins: int(row.Wins),
Losses: int(row.Losses),
Draws: int(row.Draws),
MaxGamePoints: int(row.MaxGamePoints),
MaxWordPoints: int(row.MaxWordPoints),
Moves: int(row.Moves),
HintsUsed: int(row.HintsUsed),
BestMoves: best,
}, nil
}
// bestMoves reads an account's per-variant best moves, ordered by variant for a stable
// response. Each row's tiles JSON is decoded into the served BestMoveTile slice. An
// account with no recorded play yields an empty (nil) slice rather than an error.
func (s *Store) bestMoves(ctx context.Context, id uuid.UUID) ([]BestMove, error) {
stmt := postgres.SELECT(
table.AccountBestMove.Variant,
table.AccountBestMove.Score,
table.AccountBestMove.Tiles,
).
FROM(table.AccountBestMove).
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(id))).
ORDER_BY(table.AccountBestMove.Variant.ASC())
var rows []model.AccountBestMove
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("account: best moves %s: %w", id, err)
}
out := make([]BestMove, 0, len(rows))
for _, r := range rows {
var tiles []BestMoveTile
if err := json.Unmarshal([]byte(r.Tiles), &tiles); err != nil {
return nil, fmt.Errorf("account: decode best-move tiles %s/%s: %w", id, r.Variant, err)
}
out = append(out, BestMove{Variant: r.Variant, Score: int(r.Score), Tiles: tiles})
}
return out, nil
}