Files
scrabble-game/backend/internal/server/handlers_game.go
T
Ilia Denisov f9faebfa91
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
fix(hint): stop the hint count going stale across games
The in-game hint badge re-fetched on entry and, for a game where it was the
player's turn, showed a too-high count that "reset" (e.g. back to 11) — the
wallet hint spent in another game was not reflected.

Root cause: the server sends one hints_remaining = per-game allowance + global
wallet, and the client cached that combined number per game. The wallet is
global, so spending a wallet hint in one game left every other game's cached
count stale (a my-turn game holds the stalest value: the opponent-moved delta
preserves the old number, whereas a game you just moved in re-cached a fresh
one). The backend allowance-then-wallet spend order was already correct.

Fix: split the two. StateView/HintResult gain a trailing wallet_balance field
(the global wallet alone); the client derives the per-game allowance as
hints_remaining - wallet_balance (stable, cacheable) and reads the wallet live
from the profile, refreshing it from every state/hint response. The badge is
allowance + live wallet, so a wallet hint anywhere updates every game at once.

- wire: scrabble.fbs StateView/HintResult + pkg/wire.BuildStateView (the single
  encoder for both the gateway transcode and the backend's event StateView),
  gateway encode + resp structs, regen.
- backend: game StateView/HintResult + service (GameState/Hint) + eventwire +
  notify PlayerState/encode + server DTOs.
- ui: lib/hints.ts (pure hintsLeft), Game.svelte (badge + syncWallet on
  load/hint, carry wallet_balance through applyMoveResult), codec/model, mock.
- docs: ARCHITECTURE §Hint.

Tests: hints.ts unit (incl. the staleness case), TestHintPolicy extended
(wallet_balance + allowance-first), gateway state/hint round-trips.
2026-06-17 16:50:16 +02:00

554 lines
16 KiB
Go

package server
import (
"context"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// The handlers below cover the game and chat operations the UI needs. They follow
// the same pattern as handlers_user.go: X-User-ID identity, the domain service
// call, a JSON DTO mapped from the result.
// hintResultDTO is the top-ranked move plus the remaining hint budget.
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
// (main word first) and the orientation the engine inferred ("H"/"V", empty when
// illegal).
type evalResultDTO struct {
Legal bool `json:"legal"`
Score int `json:"score"`
Words []string `json:"words"`
Dir string `json:"dir"`
}
// wordCheckDTO is the result of the unlimited dictionary lookup tool.
type wordCheckDTO struct {
Word string `json:"word"`
Legal bool `json:"legal"`
}
// historyDTO is a game's decoded move journal, the source for client board replay.
type historyDTO struct {
GameID string `json:"game_id"`
Moves []moveRecordDTO `json:"moves"`
}
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
// reports whether the caller has reached the simultaneous quick-game cap
// (game.MaxActiveQuickGames); while it is true the lobby disables "New Game" and shows a
// notice. It rides the lobby response — which the lobby re-fetches on every game event —
// instead of a separate request.
type gameListDTO struct {
Games []gameDTO `json:"games"`
AtGameLimit bool `json:"at_game_limit"`
}
// chatListDTO is a game's chat history.
type chatListDTO struct {
Messages []chatDTO `json:"messages"`
}
// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet
// indices; a blank is engine.BlankIndex.
type exchangeRequest struct {
Tiles []int `json:"tiles"`
}
// complaintRequest disputes a word-check result.
type complaintRequest struct {
Word string `json:"word"`
Note string `json:"note"`
}
// fillSeatNames fills each seat's display name from the account store — memoising across
// seats and games within one request — for the seats that carry no per-game snapshot yet
// (a pre-snapshot legacy row). A seat whose snapshot is already set is left untouched, so
// a disguised robot keeps its per-game name rather than reverting to its account name.
func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]string) {
for i := range g.Seats {
if g.Seats[i].DisplayName != "" {
continue // the seat already carries its per-game snapshot
}
id := g.Seats[i].AccountID
name, ok := memo[id]
if !ok {
if uid, err := uuid.Parse(id); err == nil {
if acc, err := s.accounts.GetByID(ctx, uid); err == nil {
name = acc.DisplayName
}
}
memo[id] = name
}
g.Seats[i].DisplayName = name
}
}
// moveRecordDTOFromHistory projects a journal move into the shared move DTO.
func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO {
tiles := make([]tileDTO, 0, len(m.Tiles))
for _, t := range m.Tiles {
tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
return moveRecordDTO{
Player: m.Seat,
Action: m.Action,
Dir: m.Dir,
MainRow: m.MainRow,
MainCol: m.MainCol,
Tiles: tiles,
Words: m.Words,
Count: len(m.Words),
Score: m.Score,
Total: m.RunningTotal,
}
}
// handlePass forfeits the player's turn.
func (s *Server) handlePass(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
res, err := s.games.Pass(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
s.writeMoveResult(c, res)
}
// handleExchange swaps the chosen rack tiles back into the bag.
func (s *Server) handleExchange(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
var req exchangeRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
tiles, err := engine.DecodeTiles(variant, req.Tiles)
if err != nil {
s.abortErr(c, err)
return
}
res, err := s.games.Exchange(c.Request.Context(), gameID, uid, tiles)
if err != nil {
s.abortErr(c, err)
return
}
s.writeMoveResult(c, res)
}
// handleResign resigns the player from the game.
func (s *Server) handleResign(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
res, err := s.games.Resign(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
s.writeMoveResult(c, res)
}
// handleHint reveals the top-ranked move and spends a hint.
func (s *Server) handleHint(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
h, err := s.games.Hint(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, hintResultDTO{
Move: moveRecordDTOFrom(h.Move),
HintsRemaining: h.HintsRemaining,
WalletBalance: h.WalletBalance,
})
}
// handleEvaluate previews a tentative play's legality and score.
func (s *Server) handleEvaluate(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
var req submitPlayRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
tiles, err := tilesFromRequest(variant, req)
if err != nil {
s.abortErr(c, err)
return
}
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, tiles)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words, Dir: ev.Dir})
}
// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as
// repeated ?idx= alphabet indices; the backend decodes them to the concrete
// word for the lookup and echoes that concrete word back for the client's result cache.
func (s *Server) handleCheckWord(c *gin.Context) {
_, gameID, ok := s.userGame(c)
if !ok {
return
}
idx, err := queryIndexes(c, "idx")
if err != nil {
abortBadRequest(c, "invalid word")
return
}
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
word, err := engine.DecodeWord(variant, idx)
if err != nil {
s.abortErr(c, err)
return
}
legal, err := s.games.CheckWord(c.Request.Context(), gameID, word)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal})
}
// queryIndexes parses repeated integer query parameters (e.g. ?idx=2&idx=0) into a slice.
// It carries a word-check query as alphabet indices on a GET.
func queryIndexes(c *gin.Context, key string) ([]int, error) {
raw := c.QueryArray(key)
out := make([]int, 0, len(raw))
for _, s := range raw {
n, err := strconv.Atoi(s)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, nil
}
// handleComplaint files a word-check complaint into the admin review queue.
func (s *Server) handleComplaint(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
var req complaintRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if _, err := s.games.FileComplaint(c.Request.Context(), gameID, uid, req.Word, req.Note); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleHistory returns a game's decoded move journal for board replay / history.
func (s *Server) handleHistory(c *gin.Context) {
_, gameID, ok := s.userGame(c)
if !ok {
return
}
view, err := s.games.History(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
moves := make([]moveRecordDTO, 0, len(view.Moves))
for _, m := range view.Moves {
moves = append(moves, moveRecordDTOFromHistory(m))
}
c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves})
}
// gcgDTO is a game's GCG export: a suggested filename plus the GCG text.
type gcgDTO struct {
GameID string `json:"game_id"`
Filename string `json:"filename"`
Content string `json:"content"`
}
// handleExportGCG returns a finished game's GCG transcript for download/share. The
// service refuses an active game (ErrGameActive) to avoid leaking the live journal.
func (s *Server) handleExportGCG(c *gin.Context) {
_, gameID, ok := s.userGame(c)
if !ok {
return
}
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, gcgDTO{
GameID: gameID.String(),
Filename: "game-" + gameID.String() + ".gcg",
Content: gcg,
})
}
// draftTileDTO is one tile a player has laid on the board but not yet submitted.
type draftTileDTO struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank"`
}
// draftDTO is a player's persisted client-side composition for a game: the
// preferred rack tile order (an opaque client string) and the board tiles laid but not yet
// submitted. The gateway forwards the JSON verbatim; this layer owns its shape.
type draftDTO struct {
RackOrder string `json:"rack_order"`
BoardTiles []draftTileDTO `json:"board_tiles"`
}
// draftDTOFrom projects a stored draft into its wire DTO.
func draftDTOFrom(d game.Draft) draftDTO {
tiles := make([]draftTileDTO, 0, len(d.BoardTiles))
for _, t := range d.BoardTiles {
tiles = append(tiles, draftTileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
return draftDTO{RackOrder: d.RackOrder, BoardTiles: tiles}
}
// toDomain maps an inbound draft DTO to the domain draft.
func (d draftDTO) toDomain() game.Draft {
tiles := make([]game.DraftTile, 0, len(d.BoardTiles))
for _, t := range d.BoardTiles {
tiles = append(tiles, game.DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
return game.Draft{RackOrder: d.RackOrder, BoardTiles: tiles}
}
// handleGetDraft returns the player's saved composition for a game, or an empty
// draft when none is stored.
func (s *Server) handleGetDraft(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
d, err := s.games.GetDraft(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, draftDTOFrom(d))
}
// handleSaveDraft upserts the player's composition for a game. The service
// rejects a non-player with ErrNotAPlayer.
func (s *Server) handleSaveDraft(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
var req draftDTO
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if err := s.games.SaveDraft(c.Request.Context(), gameID, uid, req.toDomain()); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// atGameLimit reports whether uid already holds the maximum number of simultaneous
// quick games (game.MaxActiveQuickGames). It backs both the lobby's at_game_limit flag
// and the new-game gate; friend games created by invitation are not counted.
func (s *Server) atGameLimit(ctx context.Context, uid uuid.UUID) (bool, error) {
n, err := s.games.CountActiveQuickGames(ctx, uid)
if err != nil {
return false, err
}
return n >= game.MaxActiveQuickGames, nil
}
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid is at the
// simultaneous quick-game cap, and reports whether the caller may proceed. It guards
// every new-game entry point — quick auto-match/AI and invitation creation; accepting an
// incoming invitation is deliberately exempt.
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID) bool {
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return false
}
if atLimit {
s.abortErr(c, game.ErrGameLimitReached)
return false
}
return true
}
// handleListGames returns the caller's active and finished games for the lobby. It omits
// the honest-AI games the caller left (resigned or abandoned to timeout); see
// game.Service.ListForLobby.
func (s *Server) handleListGames(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
games, err := s.games.ListForLobby(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
memo := map[string]string{}
// One query seeds the lobby's per-card unread badge for every listed game.
var unread map[uuid.UUID]bool
if s.social != nil {
unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
}
out := make([]gameDTO, 0, len(games))
for _, g := range games {
dto := gameDTOFromGame(g)
s.fillSeatNames(c.Request.Context(), &dto, memo)
dto.UnreadChat = unread[g.ID]
out = append(out, dto)
}
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
}
// handleChatList returns a game's chat history for the viewer.
func (s *Server) handleChatList(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
msgs, err := s.social.Messages(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
out := make([]chatDTO, 0, len(msgs))
for _, m := range msgs {
out = append(out, chatDTOFrom(m))
}
c.JSON(http.StatusOK, chatListDTO{Messages: out})
}
// handleNudge posts a nudge to the player whose turn is awaited.
func (s *Server) handleNudge(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
msg, err := s.social.Nudge(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, chatDTOFrom(msg))
}
// handleChatRead marks every chat entry the caller has not yet read in the game as read
// — the acknowledgement the client sends when the player opens the move history or chat
// — and records each entry's publish-to-read latency. The client calls it only when it
// believes something is unread, so the backend is not hit on every history open.
func (s *Server) handleChatRead(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
if _, err := s.social.MarkRead(c.Request.Context(), gameID, uid); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// userGame reads the authenticated account and the :id game param, aborting with the
// right status when either is missing. ok is false when the request was aborted.
func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return uuid.UUID{}, uuid.UUID{}, false
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return uuid.UUID{}, uuid.UUID{}, false
}
return uid, gameID, true
}
// writeMoveResult emits a committed move with seat display names filled in.
func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
dto, err := moveResultDTOFrom(res)
if err != nil {
s.abortErr(c, err)
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
if uid, ok := userID(c); ok {
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
}
c.JSON(http.StatusOK, dto)
}
// setUnreadChat fills the per-game unread-chat flag for viewer uid on one game's DTO,
// when the social domain is present. It is best-effort: a lookup error leaves the flag
// false (a missing badge) rather than failing the surrounding game response.
func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
if s.social == nil {
return
}
gid, err := uuid.Parse(g.ID)
if err != nil {
return
}
if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil {
g.UnreadChat = unread
}
}