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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Package accountmerge retires a secondary account into a primary one in a single
|
||||
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
|
||||
// transaction: it sums statistics (merging the per-variant best moves), sums the hint
|
||||
// wallet, ORs the paid flag, repoints
|
||||
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
||||
// (accounts.merged_into). It is the data core of account linking & merge
|
||||
@@ -68,6 +69,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
|
||||
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
|
||||
// summed, max points kept) and deletes the secondary row.
|
||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
|
||||
// the moves/hints-used counters summed, max points kept) and deletes the secondary row.
|
||||
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.AccountStats
|
||||
err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
@@ -178,13 +182,16 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
|
||||
|
||||
upd := table.AccountStats.UPDATE(
|
||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints,
|
||||
table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
|
||||
).SET(
|
||||
postgres.Int(int64(pri.Wins+sec.Wins)),
|
||||
postgres.Int(int64(pri.Losses+sec.Losses)),
|
||||
postgres.Int(int64(pri.Draws+sec.Draws)),
|
||||
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
||||
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
||||
postgres.Int(int64(pri.Moves+sec.Moves)),
|
||||
postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
@@ -198,6 +205,41 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
|
||||
// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
|
||||
// the secondary's rows — the secondary is only tombstoned, not removed, so without this
|
||||
// they would linger on a dead account and never reach the merged statistics screen.
|
||||
func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var srows []model.AccountBestMove
|
||||
err := postgres.SELECT(table.AccountBestMove.AllColumns).
|
||||
FROM(table.AccountBestMove).
|
||||
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &srows)
|
||||
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
|
||||
}
|
||||
for _, s := range srows {
|
||||
ins := table.AccountBestMove.
|
||||
INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
|
||||
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
|
||||
VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
|
||||
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
|
||||
DO_UPDATE(postgres.SET(
|
||||
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
|
||||
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
|
||||
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
|
||||
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
|
||||
}
|
||||
}
|
||||
del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||
// all other profile fields stay the primary's.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
||||
<li><b>Moves</b> {{.Stats.Moves}}</li>
|
||||
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
|
||||
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||
</ul>
|
||||
|
||||
@@ -203,6 +203,8 @@ type StatsRow struct {
|
||||
Draws int
|
||||
MaxGamePoints int
|
||||
MaxWordPoints int
|
||||
Moves int
|
||||
HintsUsed int
|
||||
}
|
||||
|
||||
// IdentityRow is one platform/email identity of an account.
|
||||
|
||||
@@ -74,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
|
||||
@@ -1027,12 +1027,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
}
|
||||
|
||||
walletAfter := acc.HintBalance
|
||||
if fromAllowance {
|
||||
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
used++
|
||||
} else {
|
||||
if !fromAllowance {
|
||||
spent, err := svc.accounts.SpendHint(ctx, accountID)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
@@ -1042,7 +1037,14 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
}
|
||||
walletAfter--
|
||||
}
|
||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
|
||||
// hints_used is the per-game total (allowance + wallet): every hint increments it. The first
|
||||
// HintsPerPlayer hints are the free allowance (so fromAllowance above stays correct); the rest
|
||||
// are charged to the wallet. Counting all hints feeds the player's lifetime hint statistics.
|
||||
if err := svc.store.IncHintsUsed(ctx, gameID, seat); err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
used++
|
||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
|
||||
}
|
||||
|
||||
// Candidates returns the to-move player's legal plays for a seated player on
|
||||
@@ -1130,6 +1132,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
Rack: g.Hand(seat),
|
||||
BagLen: g.BagLen(),
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||
WalletBalance: acc.HintBalance,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1419,19 +1422,45 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
|
||||
}
|
||||
|
||||
// buildStats derives each seat's statistics contribution from a finished game:
|
||||
// win/loss/draw from the (resignation-aware) winner, the final score, and the
|
||||
// best single-move score from the log.
|
||||
// win/loss/draw from the (resignation-aware) winner, the final score, and the best
|
||||
// single play from the log — its score and, for the per-variant breakdown, its main
|
||||
// word as rendering tiles. Blank flags are taken from every blank ever placed (so a
|
||||
// blank laid by an earlier move and embedded in the best word is honoured), which is
|
||||
// equivalent to reading the final board since a placed tile never moves.
|
||||
func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||||
res := g.Result()
|
||||
best := make(map[int]int)
|
||||
bestRec := make(map[int]engine.MoveRecord)
|
||||
blanks := make(map[[2]int]bool)
|
||||
plays := make(map[int]int) // per player: count of plays (tile placements), for the "moves" stat
|
||||
for _, rec := range g.Log() {
|
||||
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
|
||||
best[rec.Player] = rec.Score
|
||||
if rec.Action != engine.ActionPlay {
|
||||
continue
|
||||
}
|
||||
plays[rec.Player]++
|
||||
for _, t := range rec.Tiles {
|
||||
if t.Blank {
|
||||
blanks[[2]int{t.Row, t.Col}] = true
|
||||
}
|
||||
}
|
||||
if cur, ok := bestRec[rec.Player]; !ok || rec.Score > cur.Score {
|
||||
bestRec[rec.Player] = rec
|
||||
}
|
||||
}
|
||||
variant := g.Variant().String()
|
||||
values := letterValues(g.Variant())
|
||||
out := make([]statDelta, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
|
||||
// moves counts the seat's plays; hintsUsed is the seat's total hints this game. Both are
|
||||
// summed into account_stats so the screen can show the hint share (hints_used / moves).
|
||||
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), moves: plays[s.Seat], hintsUsed: s.HintsUsed}
|
||||
if rec, ok := bestRec[s.Seat]; ok {
|
||||
d.wordPoints = rec.Score
|
||||
if rec.Score > 0 {
|
||||
d.bestVariant = variant
|
||||
d.bestScore = rec.Score
|
||||
d.bestTiles = mainWordTiles(rec, blanks, values)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case res.Winner < 0:
|
||||
d.draws = 1
|
||||
@@ -1445,6 +1474,49 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||||
return out
|
||||
}
|
||||
|
||||
// letterValues builds a lower-cased letter -> tile value lookup for a variant from the
|
||||
// engine's alphabet table, so a best-move word can be rendered with per-tile values on a
|
||||
// screen (statistics) that has not cached the variant's alphabet. It is empty for an
|
||||
// unrecognised variant, leaving every value zero.
|
||||
func letterValues(v engine.Variant) map[string]int {
|
||||
table, err := engine.AlphabetTable(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]int, len(table))
|
||||
for _, e := range table {
|
||||
m[strings.ToLower(e.Letter)] = e.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// mainWordTiles decodes a play's main word into rendering tiles: each letter with its
|
||||
// tile value (0 for a blank) and blank flag. blanks is the set of board coordinates a
|
||||
// blank was ever placed on; values maps a lower-cased letter to its tile value. It walks
|
||||
// the word from its first-letter coordinate along the play's orientation.
|
||||
func mainWordTiles(rec engine.MoveRecord, blanks map[[2]int]bool, values map[string]int) []account.BestMoveTile {
|
||||
if len(rec.Words) == 0 {
|
||||
return nil
|
||||
}
|
||||
dr, dc := 0, 1
|
||||
if rec.Dir == engine.Vertical {
|
||||
dr, dc = 1, 0
|
||||
}
|
||||
letters := []rune(rec.Words[0])
|
||||
out := make([]account.BestMoveTile, len(letters))
|
||||
for i, r := range letters {
|
||||
row, col := rec.MainRow+i*dr, rec.MainCol+i*dc
|
||||
blank := blanks[[2]int{row, col}]
|
||||
letter := string(r)
|
||||
value := 0
|
||||
if !blank {
|
||||
value = values[strings.ToLower(letter)]
|
||||
}
|
||||
out[i] = account.BestMoveTile{Letter: letter, Value: value, Blank: blank}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nonGuestSeats filters out guest seats so the finish-time statistics are
|
||||
// recomputed for durable non-guest accounts only — guests never accrue
|
||||
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// TestMainWordTilesHorizontalWithBlank checks that a horizontal main word is decoded into
|
||||
// tiles along its row, that the per-letter value is looked up from the value table, and that
|
||||
// a blank cell (here the middle 'a') is rendered with a zero value regardless of its letter
|
||||
// value — the blank flag is taken from the placed-blank set, not from this move's own tiles.
|
||||
func TestMainWordTilesHorizontalWithBlank(t *testing.T) {
|
||||
rec := engine.MoveRecord{Dir: engine.Horizontal, MainRow: 7, MainCol: 5, Words: []string{"cat"}}
|
||||
blanks := map[[2]int]bool{{7, 6}: true}
|
||||
values := map[string]int{"c": 3, "a": 1, "t": 1}
|
||||
got := mainWordTiles(rec, blanks, values)
|
||||
want := []account.BestMoveTile{
|
||||
{Letter: "c", Value: 3, Blank: false},
|
||||
{Letter: "a", Value: 0, Blank: true},
|
||||
{Letter: "t", Value: 1, Blank: false},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mainWordTiles = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainWordTilesVerticalCyrillic checks vertical walk and multi-byte (Cyrillic) letters:
|
||||
// the word must be split by rune, not byte, and laid down its column.
|
||||
func TestMainWordTilesVerticalCyrillic(t *testing.T) {
|
||||
rec := engine.MoveRecord{Dir: engine.Vertical, MainRow: 3, MainCol: 8, Words: []string{"съёмка"}}
|
||||
values := map[string]int{"с": 1, "ъ": 10, "ё": 4, "м": 2, "к": 2, "а": 1}
|
||||
got := mainWordTiles(rec, map[[2]int]bool{}, values)
|
||||
want := []account.BestMoveTile{
|
||||
{Letter: "с", Value: 1}, {Letter: "ъ", Value: 10}, {Letter: "ё", Value: 4},
|
||||
{Letter: "м", Value: 2}, {Letter: "к", Value: 2}, {Letter: "а", Value: 1},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mainWordTiles = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainWordTilesNoWord checks the defensive nil for a record carrying no words.
|
||||
func TestMainWordTilesNoWord(t *testing.T) {
|
||||
if got := mainWordTiles(engine.MoveRecord{}, nil, nil); got != nil {
|
||||
t.Errorf("mainWordTiles(no words) = %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package game
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
@@ -53,13 +55,22 @@ type gameInsert struct {
|
||||
}
|
||||
|
||||
// statDelta is one account's contribution to its statistics on a game finish.
|
||||
// bestVariant/bestScore/bestTiles describe the game's best play for this account when
|
||||
// it scored (bestVariant empty otherwise): the variant label, the play's total score and
|
||||
// its main word as rendering tiles. They feed the per-variant account_best_move upsert,
|
||||
// which keeps only the account's highest-scoring play per variant.
|
||||
type statDelta struct {
|
||||
accountID uuid.UUID
|
||||
wins int
|
||||
losses int
|
||||
draws int
|
||||
gamePoints int
|
||||
wordPoints int
|
||||
accountID uuid.UUID
|
||||
wins int
|
||||
losses int
|
||||
draws int
|
||||
gamePoints int
|
||||
wordPoints int
|
||||
moves int // plays this game (tile placements), summed into account_stats.moves
|
||||
hintsUsed int // hints used this game (allowance + wallet), summed into account_stats.hints_used
|
||||
bestVariant string
|
||||
bestScore int
|
||||
bestTiles []account.BestMoveTile
|
||||
}
|
||||
|
||||
// commit is everything a single committed transition persists: the journal row,
|
||||
@@ -633,6 +644,9 @@ func (s *Store) CommitMove(ctx context.Context, c commit) error {
|
||||
if err := upsertStats(ctx, tx, d, c.now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertBestMove(ctx, tx, d, c.now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -662,6 +676,9 @@ func (s *Store) VoidGame(ctx context.Context, v voidCommit) error {
|
||||
if err := upsertStats(ctx, tx, d, v.now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertBestMove(ctx, tx, d, v.now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -714,13 +731,17 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
|
||||
draws := row.Draws + int32(d.draws)
|
||||
maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
|
||||
maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
|
||||
moves := row.Moves + int32(d.moves)
|
||||
hintsUsed := row.HintsUsed + int32(d.hintsUsed)
|
||||
|
||||
upd := table.AccountStats.UPDATE(
|
||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
|
||||
table.AccountStats.Moves, table.AccountStats.HintsUsed,
|
||||
).SET(
|
||||
postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)),
|
||||
postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now),
|
||||
postgres.Int(int64(moves)), postgres.Int(int64(hintsUsed)),
|
||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("update stats %s: %w", d.accountID, err)
|
||||
@@ -728,8 +749,41 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpendHintAllowance increments a seat's per-game hint counter by one.
|
||||
func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
|
||||
// upsertBestMove records the account's best play for a variant, keeping only the
|
||||
// highest-scoring one: a first play inserts, a later one replaces it only when it scored
|
||||
// strictly higher (the conditional DO UPDATE makes the upsert atomic under concurrent
|
||||
// finishes without a separate lock). It is a no-op when the finish carries no scoring play
|
||||
// for the account (a draw with no plays, or an exchange/pass-only game).
|
||||
func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
|
||||
if d.bestVariant == "" || len(d.bestTiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
tiles, err := json.Marshal(d.bestTiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal best move %s/%s: %w", d.accountID, d.bestVariant, err)
|
||||
}
|
||||
stmt := table.AccountBestMove.
|
||||
INSERT(
|
||||
table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
|
||||
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt,
|
||||
).
|
||||
VALUES(d.accountID, d.bestVariant, d.bestScore, string(tiles), postgres.TimestampzT(now)).
|
||||
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
|
||||
DO_UPDATE(postgres.SET(
|
||||
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
|
||||
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
|
||||
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
|
||||
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("upsert best move %s/%s: %w", d.accountID, d.bestVariant, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for
|
||||
// every hint — both the free per-game allowance and the wallet-charged ones — so the counter
|
||||
// is the seat's total hints used this game (the first HintsPerPlayer being the allowance).
|
||||
func (s *Store) IncHintsUsed(ctx context.Context, gameID uuid.UUID, seat int) error {
|
||||
stmt := table.GamePlayers.
|
||||
UPDATE(table.GamePlayers.HintsUsed).
|
||||
SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
|
||||
@@ -738,7 +792,7 @@ func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat i
|
||||
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("game: spend hint allowance: %w", err)
|
||||
return fmt.Errorf("game: increment hints used: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ type Seat struct {
|
||||
Seat int
|
||||
AccountID uuid.UUID
|
||||
Score int
|
||||
// HintsUsed is the total hints the seat used this game — both the free per-game allowance
|
||||
// and the wallet-charged ones (the first HintsPerPlayer being the allowance).
|
||||
HintsUsed int
|
||||
IsWinner bool
|
||||
// DisplayName is the seat's display-name snapshot, captured when the seat was taken
|
||||
@@ -194,10 +196,13 @@ type MoveResult struct {
|
||||
}
|
||||
|
||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
||||
// budget (per-seat allowance plus profile wallet) after spending one.
|
||||
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
|
||||
// the global wallet alone, so the client can keep its live wallet authoritative and
|
||||
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
|
||||
type HintResult struct {
|
||||
Move engine.MoveRecord
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
}
|
||||
|
||||
// EvalResult previews a tentative play without committing it. Dir is the
|
||||
@@ -220,6 +225,9 @@ type StateView struct {
|
||||
Rack []string
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||
WalletBalance int
|
||||
}
|
||||
|
||||
// HistoryMove is one decoded journal row, independent of any dictionary.
|
||||
|
||||
@@ -89,7 +89,8 @@ func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get stats: %v", err)
|
||||
}
|
||||
if (st != account.Stats{}) {
|
||||
zero := st.Wins == 0 && st.Losses == 0 && st.Draws == 0 && st.MaxGamePoints == 0 && st.MaxWordPoints == 0
|
||||
if !zero || len(st.BestMoves) != 0 {
|
||||
t.Fatalf("fresh stats = %+v, want zero", st)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
@@ -120,8 +121,8 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
||||
t.Fatalf("final game not finished: %+v", last.Game)
|
||||
}
|
||||
|
||||
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
|
||||
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
|
||||
w0, l0, d0, mg0, mw0, ok0 := readStats(t, seats[0])
|
||||
w1, l1, d1, mg1, mw1, ok1 := readStats(t, seats[1])
|
||||
if !ok0 || !ok1 {
|
||||
t.Fatal("both players must have a stats row")
|
||||
}
|
||||
@@ -133,6 +134,52 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
||||
if !decisive && !draw {
|
||||
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
|
||||
}
|
||||
|
||||
// Each player who made a scoring play gets exactly one per-variant best move (only
|
||||
// scrabble_en was played here); its score equals the aggregate max_word_points and it
|
||||
// carries the decoded word as tiles.
|
||||
accounts := account.NewStore(testDB)
|
||||
for _, p := range []struct {
|
||||
id uuid.UUID
|
||||
maxWord int
|
||||
}{{seats[0], mw0}, {seats[1], mw1}} {
|
||||
if p.maxWord == 0 {
|
||||
continue // a player who only passed has no best move
|
||||
}
|
||||
st, err := accounts.GetStats(ctx, p.id)
|
||||
if err != nil {
|
||||
t.Fatalf("get stats: %v", err)
|
||||
}
|
||||
// The player made at least one scoring play (maxWord > 0), so the moves aggregate is
|
||||
// positive; no hints were taken in this greedy game, so the hints aggregate stays 0.
|
||||
if st.Moves <= 0 {
|
||||
t.Errorf("moves = %d, want > 0", st.Moves)
|
||||
}
|
||||
if st.HintsUsed != 0 {
|
||||
t.Errorf("hints used = %d, want 0 (no hints taken)", st.HintsUsed)
|
||||
}
|
||||
if len(st.BestMoves) != 1 {
|
||||
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
|
||||
}
|
||||
bm := st.BestMoves[0]
|
||||
if bm.Variant != engine.VariantEnglish.String() {
|
||||
t.Errorf("best move variant = %q, want %q", bm.Variant, engine.VariantEnglish.String())
|
||||
}
|
||||
if bm.Score != p.maxWord {
|
||||
t.Errorf("best move score %d != max_word_points %d", bm.Score, p.maxWord)
|
||||
}
|
||||
if len(bm.Tiles) == 0 {
|
||||
t.Error("best move word is empty")
|
||||
}
|
||||
for _, tl := range bm.Tiles {
|
||||
if tl.Letter == "" {
|
||||
t.Error("best move tile has empty letter")
|
||||
}
|
||||
if tl.Blank && tl.Value != 0 {
|
||||
t.Errorf("blank tile must score 0, got %d", tl.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplayEquivalence plays a few moves through one service, then proves a
|
||||
@@ -369,6 +416,15 @@ func TestHintPolicy(t *testing.T) {
|
||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
|
||||
t.Fatalf("first hint: %v", err)
|
||||
}
|
||||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
||||
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
|
||||
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||||
if err != nil {
|
||||
t.Fatalf("state: %v", err)
|
||||
}
|
||||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
||||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
||||
}
|
||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||||
}
|
||||
@@ -377,8 +433,18 @@ func TestHintPolicy(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("wallet hint: %v", err)
|
||||
}
|
||||
if res.HintsRemaining != 1 {
|
||||
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining)
|
||||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
||||
}
|
||||
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
||||
// that feeds the player's lifetime hint statistics, not just the allowance.
|
||||
st2, err := svc.GameState(ctx, g.ID, seats[0])
|
||||
if err != nil {
|
||||
t.Fatalf("state after wallet hint: %v", err)
|
||||
}
|
||||
if got := st2.Game.Seats[0].HintsUsed; got != 2 {
|
||||
t.Errorf("hints_used = %d after allowance + wallet hint, want 2", got)
|
||||
}
|
||||
|
||||
off, err := svc.Create(ctx, game.CreateParams{
|
||||
|
||||
@@ -30,6 +30,34 @@ func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
|
||||
}
|
||||
}
|
||||
|
||||
func setStatsCounts(t *testing.T, id uuid.UUID, moves, hints int) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE backend.account_stats SET moves=$2, hints_used=$3 WHERE account_id=$1`, id, moves, hints); err != nil {
|
||||
t.Fatalf("set stats counts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setBestMove(t *testing.T, id uuid.UUID, variant string, score int) {
|
||||
t.Helper()
|
||||
tiles := `[{"letter":"c","value":3,"blank":false}]`
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`INSERT INTO backend.account_best_move (account_id, variant, score, tiles) VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (account_id, variant) DO UPDATE SET score=$3, tiles=$4`, id, variant, score, tiles); err != nil {
|
||||
t.Fatalf("set best move: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func bestMoveCount(t *testing.T, id uuid.UUID) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT count(*) FROM backend.account_best_move WHERE account_id=$1`, id).Scan(&n); err != nil {
|
||||
t.Fatalf("best move count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
@@ -110,8 +138,15 @@ func TestAccountMergeCore(t *testing.T) {
|
||||
|
||||
setStats(t, primary, 1, 0, 0, 100, 90)
|
||||
setStats(t, secondary, 3, 1, 2, 400, 80)
|
||||
setStatsCounts(t, primary, 100, 5)
|
||||
setStatsCounts(t, secondary, 50, 8)
|
||||
setWallet(t, primary, 2, false)
|
||||
setWallet(t, secondary, 5, true)
|
||||
// Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's
|
||||
// scrabble_ru (30) is new to primary and carried over.
|
||||
setBestMove(t, primary, "scrabble_en", 50)
|
||||
setBestMove(t, secondary, "scrabble_en", 80)
|
||||
setBestMove(t, secondary, "scrabble_ru", 30)
|
||||
|
||||
email := "merge-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, secondary, email)
|
||||
@@ -153,6 +188,26 @@ func TestAccountMergeCore(t *testing.T) {
|
||||
if mergedInto(t, secondary) != primary {
|
||||
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
|
||||
}
|
||||
|
||||
// The moves/hints counters sum, and the per-variant best moves merge (the higher score
|
||||
// per variant kept), with the secondary's best-move rows cleaned up.
|
||||
st, err := store.GetStats(ctx, primary)
|
||||
if err != nil {
|
||||
t.Fatalf("get merged stats: %v", err)
|
||||
}
|
||||
if st.Moves != 150 || st.HintsUsed != 13 {
|
||||
t.Errorf("primary moves/hints = %d/%d, want 150/13", st.Moves, st.HintsUsed)
|
||||
}
|
||||
best := map[string]int{}
|
||||
for _, b := range st.BestMoves {
|
||||
best[b.Variant] = b.Score
|
||||
}
|
||||
if len(st.BestMoves) != 2 || best["scrabble_en"] != 80 || best["scrabble_ru"] != 30 {
|
||||
t.Errorf("primary best moves = %+v, want scrabble_en:80 + scrabble_ru:30", st.BestMoves)
|
||||
}
|
||||
if bestMoveCount(t, secondary) != 0 {
|
||||
t.Error("secondary best-move rows should be deleted after merge")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
|
||||
|
||||
@@ -83,6 +83,7 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
Alphabet: alphabet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ type PlayerState struct {
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
Alphabet []AlphabetLetter
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AccountBestMove struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Variant string `sql:"primary_key"`
|
||||
Score int32
|
||||
Tiles string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -20,4 +20,6 @@ type AccountStats struct {
|
||||
MaxGamePoints int32
|
||||
MaxWordPoints int32
|
||||
UpdatedAt time.Time
|
||||
Moves int32
|
||||
HintsUsed int32
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var AccountBestMove = newAccountBestMoveTable("backend", "account_best_move", "")
|
||||
|
||||
type accountBestMoveTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
Variant postgres.ColumnString
|
||||
Score postgres.ColumnInteger
|
||||
Tiles postgres.ColumnString
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type AccountBestMoveTable struct {
|
||||
accountBestMoveTable
|
||||
|
||||
EXCLUDED accountBestMoveTable
|
||||
}
|
||||
|
||||
// AS creates new AccountBestMoveTable with assigned alias
|
||||
func (a AccountBestMoveTable) AS(alias string) *AccountBestMoveTable {
|
||||
return newAccountBestMoveTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new AccountBestMoveTable with assigned schema name
|
||||
func (a AccountBestMoveTable) FromSchema(schemaName string) *AccountBestMoveTable {
|
||||
return newAccountBestMoveTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new AccountBestMoveTable with assigned table prefix
|
||||
func (a AccountBestMoveTable) WithPrefix(prefix string) *AccountBestMoveTable {
|
||||
return newAccountBestMoveTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new AccountBestMoveTable with assigned table suffix
|
||||
func (a AccountBestMoveTable) WithSuffix(suffix string) *AccountBestMoveTable {
|
||||
return newAccountBestMoveTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newAccountBestMoveTable(schemaName, tableName, alias string) *AccountBestMoveTable {
|
||||
return &AccountBestMoveTable{
|
||||
accountBestMoveTable: newAccountBestMoveTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newAccountBestMoveTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newAccountBestMoveTableImpl(schemaName, tableName, alias string) accountBestMoveTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
VariantColumn = postgres.StringColumn("variant")
|
||||
ScoreColumn = postgres.IntegerColumn("score")
|
||||
TilesColumn = postgres.StringColumn("tiles")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, VariantColumn, ScoreColumn, TilesColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{ScoreColumn, TilesColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return accountBestMoveTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
Variant: VariantColumn,
|
||||
Score: ScoreColumn,
|
||||
Tiles: TilesColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type accountStatsTable struct {
|
||||
MaxGamePoints postgres.ColumnInteger
|
||||
MaxWordPoints postgres.ColumnInteger
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
Moves postgres.ColumnInteger
|
||||
HintsUsed postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -72,9 +74,11 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
|
||||
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
|
||||
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
MovesColumn = postgres.IntegerColumn("moves")
|
||||
HintsUsedColumn = postgres.IntegerColumn("hints_used")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
|
||||
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
|
||||
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
|
||||
)
|
||||
|
||||
return accountStatsTable{
|
||||
@@ -88,6 +92,8 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
|
||||
MaxGamePoints: MaxGamePointsColumn,
|
||||
MaxWordPoints: MaxWordPointsColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
Moves: MovesColumn,
|
||||
HintsUsed: HintsUsedColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -131,7 +131,8 @@ CREATE INDEX games_open_idx ON games (open_deadline_at) WHERE status = 'open';
|
||||
-- account, or NULL for the still-empty opponent seat of an auto-match game waiting for an
|
||||
-- opponent (status='open'); it is filled when a human or a robot joins. score is the
|
||||
-- running/final score, is_winner is stamped on finish (false for every seat on a draw),
|
||||
-- hints_used counts the per-game allowance consumed before the profile wallet.
|
||||
-- hints_used counts the seat's total hints used this game (the free per-game allowance plus
|
||||
-- the wallet-charged ones; the first HintsPerPlayer are the allowance).
|
||||
CREATE TABLE game_players (
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
seat smallint NOT NULL,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- +goose Up
|
||||
-- Per-account, per-variant best single move: the highest-scoring play the account has
|
||||
-- ever made in each game variant. It exists so the statistics screen can show the word
|
||||
-- itself broken down by variant, not just the dimensionless aggregate
|
||||
-- account_stats.max_word_points. tiles is the move's main word as an ordered JSON array of
|
||||
-- {letter, value, blank} objects (value 0 and blank true for a wildcard), so the client
|
||||
-- renders it as game tiles without needing the variant's alphabet table. score is the
|
||||
-- play's total points (every word it formed plus the all-tiles bonus), matching
|
||||
-- max_word_points. A row is replaced only by a strictly higher-scoring play. It is written
|
||||
-- at game finish alongside account_stats; guest and honest-AI games never record statistics,
|
||||
-- so they never write here. See docs/ARCHITECTURE.md §9.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
CREATE TABLE account_best_move (
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
variant text NOT NULL,
|
||||
score integer NOT NULL,
|
||||
tiles jsonb NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (account_id, variant)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
DROP TABLE IF EXISTS account_best_move;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- account_stats gains two lifetime counters for the player's statistics screen: moves — the
|
||||
-- player's plays (tile placements; passes and exchanges do not count) — and hints_used — every
|
||||
-- hint the player took (the free per-game allowance plus the wallet-charged ones). Both are
|
||||
-- summed at game finish over the same games that feed the rest of account_stats (durable
|
||||
-- non-guest accounts; honest-AI games are skipped), so the screen can show the "hint share"
|
||||
-- = hints_used / moves. See docs/ARCHITECTURE.md §9.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE account_stats ADD COLUMN moves integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE account_stats ADD COLUMN hints_used integer NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
ALTER TABLE account_stats DROP COLUMN hints_used;
|
||||
ALTER TABLE account_stats DROP COLUMN moves;
|
||||
@@ -140,6 +140,7 @@ type stateDTO struct {
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||
}
|
||||
|
||||
@@ -291,6 +292,7 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
|
||||
@@ -29,13 +29,27 @@ type updateProfileRequest struct {
|
||||
}
|
||||
|
||||
// statsDTO is a durable account's lifetime statistics (the derived games-played and
|
||||
// win-rate are computed client-side).
|
||||
// win-rate are computed client-side). BestMoves breaks the best move down per variant,
|
||||
// carrying the word itself; it is absent for an account with no recorded play and never
|
||||
// lists a variant the account has not played.
|
||||
type statsDTO struct {
|
||||
Wins int `json:"wins"`
|
||||
Losses int `json:"losses"`
|
||||
Draws int `json:"draws"`
|
||||
MaxGamePoints int `json:"max_game_points"`
|
||||
MaxWordPoints int `json:"max_word_points"`
|
||||
Wins int `json:"wins"`
|
||||
Losses int `json:"losses"`
|
||||
Draws int `json:"draws"`
|
||||
MaxGamePoints int `json:"max_game_points"`
|
||||
MaxWordPoints int `json:"max_word_points"`
|
||||
Moves int `json:"moves"`
|
||||
HintsUsed int `json:"hints_used"`
|
||||
BestMoves []bestMoveDTO `json:"best_moves,omitempty"`
|
||||
}
|
||||
|
||||
// bestMoveDTO is one variant's best play: the variant label, the play's total score and
|
||||
// its main word as ordered tiles (letter, value, blank — value 0 for a blank), so the
|
||||
// client renders it as game tiles without the variant's alphabet table.
|
||||
type bestMoveDTO struct {
|
||||
Variant string `json:"variant"`
|
||||
Score int `json:"score"`
|
||||
Word []account.BestMoveTile `json:"word"`
|
||||
}
|
||||
|
||||
// parseAwayTime parses an "HH:MM" away-window bound.
|
||||
@@ -140,11 +154,21 @@ func (s *Server) handleStats(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
var best []bestMoveDTO
|
||||
if len(st.BestMoves) > 0 {
|
||||
best = make([]bestMoveDTO, len(st.BestMoves))
|
||||
for i, b := range st.BestMoves {
|
||||
best[i] = bestMoveDTO{Variant: b.Variant, Score: b.Score, Word: b.Tiles}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, statsDTO{
|
||||
Wins: st.Wins,
|
||||
Losses: st.Losses,
|
||||
Draws: st.Draws,
|
||||
MaxGamePoints: st.MaxGamePoints,
|
||||
MaxWordPoints: st.MaxWordPoints,
|
||||
Moves: st.Moves,
|
||||
HintsUsed: st.HintsUsed,
|
||||
BestMoves: best,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
}
|
||||
if view.HasStats {
|
||||
if st, err := s.accounts.GetStats(ctx, id); err == nil {
|
||||
view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints}
|
||||
view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints, Moves: st.Moves, HintsUsed: st.HintsUsed}
|
||||
}
|
||||
}
|
||||
if ids, err := s.accounts.Identities(ctx, id); err == nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
type hintResultDTO struct {
|
||||
Move moveRecordDTO `json:"move"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
}
|
||||
|
||||
// evalResultDTO is an unlimited move preview: legality, score, the words formed
|
||||
@@ -185,6 +186,7 @@ func (s *Server) handleHint(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, hintResultDTO{
|
||||
Move: moveRecordDTOFrom(h.Move),
|
||||
HintsRemaining: h.HintsRemaining,
|
||||
WalletBalance: h.WalletBalance,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user