8793bd34f2
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.
116 lines
4.1 KiB
Go
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
|
|
}
|