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
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.
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
|
|
}
|