6d1d8030e3
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.
175 lines
5.8 KiB
Go
175 lines
5.8 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"scrabble/backend/internal/account"
|
|
)
|
|
|
|
// The /api/v1/user account handlers wire profile editing, email binding and the
|
|
// statistics read. They follow handlers_user.go: X-User-ID identity, a
|
|
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
|
|
// client sends the complete desired profile.
|
|
|
|
// updateProfileRequest is the full editable profile. away_start/away_end are
|
|
// "HH:MM" local-time bounds of the daily away window.
|
|
type updateProfileRequest struct {
|
|
DisplayName string `json:"display_name"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
AwayStart string `json:"away_start"`
|
|
AwayEnd string `json:"away_end"`
|
|
BlockChat bool `json:"block_chat"`
|
|
BlockFriendRequests bool `json:"block_friend_requests"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// statsDTO is a durable account's lifetime statistics (the derived games-played and
|
|
// 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"`
|
|
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.
|
|
func parseAwayTime(s string) (time.Time, bool) {
|
|
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
// handleUpdateProfile overwrites the caller's editable profile fields.
|
|
func (s *Server) handleUpdateProfile(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req updateProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
awayStart, ok := parseAwayTime(req.AwayStart)
|
|
if !ok {
|
|
abortBadRequest(c, "away_start must be HH:MM")
|
|
return
|
|
}
|
|
awayEnd, ok := parseAwayTime(req.AwayEnd)
|
|
if !ok {
|
|
abortBadRequest(c, "away_end must be HH:MM")
|
|
return
|
|
}
|
|
acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{
|
|
DisplayName: req.DisplayName,
|
|
PreferredLanguage: req.PreferredLanguage,
|
|
TimeZone: req.TimeZone,
|
|
AwayStart: awayStart,
|
|
AwayEnd: awayEnd,
|
|
BlockChat: req.BlockChat,
|
|
BlockFriendRequests: req.BlockFriendRequests,
|
|
NotificationsInAppOnly: req.NotificationsInAppOnly,
|
|
})
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
|
|
}
|
|
|
|
// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC
|
|
// instant for a temporary block and empty for a permanent one (or when not blocked); Reason is
|
|
// the operator-set reason resolved to the account's language, empty when none was cited. It is
|
|
// the one payload a blocked client can still fetch, behind the suspension gate's exemption.
|
|
type blockStatusResponse struct {
|
|
Blocked bool `json:"blocked"`
|
|
Permanent bool `json:"permanent"`
|
|
Until string `json:"until,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// handleBlockStatus reports whether the caller is blocked and, if so, the block's expiry and the
|
|
// reason resolved to the account's language. The suspension gate exempts this route so a blocked
|
|
// client can render the terminal blocked screen.
|
|
func (s *Server) handleBlockStatus(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
susp, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
resp := blockStatusResponse{Blocked: blocked}
|
|
if blocked {
|
|
resp.Permanent = susp.Permanent()
|
|
if susp.BlockedUntil != nil {
|
|
resp.Until = susp.BlockedUntil.UTC().Format(time.RFC3339)
|
|
}
|
|
// Resolve the reason snapshot to the account's interface language; fall back to English
|
|
// if the account row cannot be read for some reason.
|
|
lang := "en"
|
|
if acc, err := s.accounts.GetByID(c.Request.Context(), uid); err == nil {
|
|
lang = acc.PreferredLanguage
|
|
}
|
|
resp.Reason = susp.LocalizedReason(lang)
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// handleStats returns the caller's lifetime statistics.
|
|
func (s *Server) handleStats(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
st, err := s.accounts.GetStats(c.Request.Context(), uid)
|
|
if err != nil {
|
|
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,
|
|
})
|
|
}
|