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
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user