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 }