feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
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:
@@ -2,6 +2,7 @@ package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@@ -13,16 +14,43 @@ import (
|
||||
"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).
|
||||
// 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
|
||||
@@ -40,11 +68,48 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user