Files
scrabble-game/backend/internal/server/handlers_game.go
T
Ilia Denisov ed53e25e57
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
feat(backend): per-tier, per-kind active-game limits with a guest funnel
Cap a player's simultaneous unfinished games per kind (vs_ai, random,
friends) with independent guest and durable-account tiers, held in a new
single-row backend.config table (-1 = unlimited) behind an in-memory cache
and editable live in the admin console (/_gm/limits). Each game is tagged
with games.game_kind on creation.

This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the
per-tier/kind config is the single mechanism, enforced at the same handler
gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable
friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the
tier and counts; the limit policy stays at the request edge.

Guests are now refused friend requests, friend-code redemption,
befriend-in-game and invitation creation outright (403 guest_forbidden) --
previously only the UI hid these.

Admin: a kind column in both game lists and the config editor.

Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
2026-07-10 09:03:57 +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"
"scrabble/backend/internal/gamelimits"
)
// 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 its tier's active-game cap for the random
// (quick auto-match) kind (the per-tier, per-kind limits in backend.config); 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})
}
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid has reached its
// tier's active-game cap for kind (the per-tier, per-kind limits in backend.config), and reports
// whether the caller may proceed. It guards the quick new-game entry points — auto-match (random)
// and AI (vs_ai). Friend-invitation limits are enforced in the lobby's CreateInvitation, and
// accepting an incoming invitation is deliberately exempt.
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID, kind gamelimits.Kind) bool {
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, kind)
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.games.AtGameLimit(c.Request.Context(), uid, gamelimits.KindRandom)
if err != nil {
s.abortErr(c, err)
return
}
memo := map[string]string{}
// Two queries seed the lobby's per-card unread badge for every listed game: which games have
// any unread entry, and which of those have a real message (so the badge can colour
// message-bearing games apart from nudge-only ones).
var unread, unreadMsg map[uuid.UUID]bool
if s.social != nil {
unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
unreadMsg, _ = s.social.UnreadMessageGames(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]
dto.UnreadMessages = unreadMsg[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 flags for viewer uid on one game's DTO, when the
// social domain is present: UnreadChat for any unread entry and UnreadMessages when one of them is
// a real message (so the badge can be coloured). 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
}
if msg, err := s.social.HasUnreadMessage(ctx, gid, uid); err == nil {
g.UnreadMessages = msg
}
}