feat(stats): per-game hints used + lifetime moves and hint share
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s

Store the hints a player used in each game, and add two lifetime tiles —
Moves and Hint share — to the statistics screen.

- per-game: game_players.hints_used now counts EVERY hint (allowance + wallet),
  not just the free allowance, so it is the seat's true total hints used this
  game. The allowance decision (used < HintsPerPlayer) and the hint badge values
  (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does
  NOT regress; only the admin "hints used" column, which silently under-counted
  wallet hints, becomes accurate.
- account_stats gains two summed counters: moves (the player's plays — passes and
  exchanges excluded) and hints_used (every hint). Computed at game finish in
  buildStats over the same non-guest, non-honest-AI games as the rest of the stats.
- wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model;
  regen.
- ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active
  locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate.
- docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN.

Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats
(moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e.
This commit is contained in:
Ilia Denisov
2026-06-17 23:44:53 +02:00
parent 91c4efc8a8
commit 6d1d8030e3
30 changed files with 224 additions and 30 deletions
+7 -1
View File
@@ -46,7 +46,11 @@ type Stats struct {
Draws int
MaxGamePoints int
MaxWordPoints int
BestMoves []BestMove
// 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
@@ -74,6 +78,8 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
Draws: int(row.Draws),
MaxGamePoints: int(row.MaxGamePoints),
MaxWordPoints: int(row.MaxWordPoints),
Moves: int(row.Moves),
HintsUsed: int(row.HintsUsed),
BestMoves: best,
}, nil
}
+13 -7
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,6 +1037,13 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
}
walletAfter--
}
// 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
}
@@ -1429,10 +1431,12 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
res := g.Result()
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 {
continue
}
plays[rec.Player]++
for _, t := range rec.Tiles {
if t.Blank {
blanks[[2]int{t.Row, t.Col}] = true
@@ -1446,7 +1450,9 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
values := letterValues(g.Variant())
out := make([]statDelta, 0, len(seats))
for _, s := range seats {
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(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 {
+11 -3
View File
@@ -66,6 +66,8 @@ type statDelta struct {
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
@@ -729,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)
@@ -774,8 +780,10 @@ func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time)
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 {
// 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))).
@@ -784,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
}
+2
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
+17
View File
@@ -150,6 +150,14 @@ func TestGameLifecycleAndStats(t *testing.T) {
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)
}
@@ -429,6 +437,15 @@ func TestHintPolicy(t *testing.T) {
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{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
@@ -20,4 +20,6 @@ type AccountStats struct {
MaxGamePoints int32
MaxWordPoints int32
UpdatedAt time.Time
Moves int32
HintsUsed int32
}
@@ -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,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;
@@ -38,6 +38,8 @@ type statsDTO struct {
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"`
}
@@ -165,6 +167,8 @@ func (s *Server) handleStats(c *gin.Context) {
Draws: st.Draws,
MaxGamePoints: st.MaxGamePoints,
MaxWordPoints: st.MaxWordPoints,
Moves: st.Moves,
HintsUsed: st.HintsUsed,
BestMoves: best,
})
}