8793bd34f2
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.
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,
|
|
})
|
|
}
|