feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s

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:
2026-06-17 22:17:27 +00:00
parent 5a3f0951ae
commit 8793bd34f2
71 changed files with 1789 additions and 132 deletions
+66 -1
View File
@@ -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
}
+46 -4
View File
@@ -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>
+2
View File
@@ -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.
+1
View File
@@ -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)
+85 -13
View File
@@ -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.
+50
View File
@@ -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)
}
}
+63 -9
View File
@@ -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
}
+9 -1
View File
@@ -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.
+2 -1
View File
@@ -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)
}
}
+70 -4
View File
@@ -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{
+55
View File
@@ -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
+1
View File
@@ -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,
})
}
+1
View File
@@ -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;
+2
View File
@@ -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)
+30 -6
View File
@@ -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 {
+2
View File
@@ -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,
})
}