cbb485ebd6
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
Replace the single "best move" number on the statistics screen with a
full-width per-variant breakdown: the highest-scoring play in each variant
the player has played, drawn as game tiles (a wildcard shows its letter but
no value), with the words and scores right-aligned to shared edges.
- backend: new account_best_move table (PK account_id+variant) keeping the
main word as JSON tiles {letter,value,blank}; captured at game finish in
buildStats (blank flags taken from every placed blank — equivalent to the
final board), upserted in the finish transaction and replaced only by a
strictly higher-scoring play. Guest/honest-AI games still record nothing.
GetStats + statsDTO expose best_moves.
- wire: StatsView gains best_moves:[BestMoveView{variant,score,word:[BestMoveTile]}]
(trailing, backward-compatible); gateway encodeStats + UI codec updated.
- ui: new WordTiles component (board's tile look, fixed px size); Stats.svelte
drops the maxWord card and adds the full-width best-move card (catalogue
order, empty variants omitted).
- docs: ARCHITECTURE §9 + schema, FUNCTIONAL (+ru), UI_DESIGN.
Tests: mainWordTiles unit + buildStats end-to-end (inttest) + gateway and UI
codec round-trips (incl. a blank tile) + e2e.
110 lines
3.8 KiB
Go
110 lines
3.8 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
|
|
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),
|
|
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
|
|
}
|