package server import ( "net/http" "slices" "github.com/gin-gonic/gin" "github.com/google/uuid" "scrabble/backend/internal/engine" "scrabble/backend/internal/gamelimits" ) // The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The // backend treats that header as the sole identity input. // handleProfile returns the authenticated account's own profile. func (s *Server) handleProfile(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } acc, err := s.accounts.GetByID(c.Request.Context(), uid) if err != nil { s.abortErr(c, err) return } // The SPA fetches the profile once per cold app-load, so stamp the account's last // login time and client IP here (throttled to at most once an hour). Best-effort: it // feeds the deletion dossier, never blocks the profile read. _ = s.accounts.StampLastLogin(c.Request.Context(), uid, clientIP(c)) c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc)) } // submitPlayRequest places tiles on the player's turn; the engine infers the // play's orientation from the tiles and the board. Each tile's Letter is a wire // alphabet index; for a blank it is the designated letter's index. type submitPlayRequest struct { Tiles []struct { Row int `json:"row"` Col int `json:"col"` Letter int `json:"letter"` Blank bool `json:"blank"` } `json:"tiles"` } // tilesFromRequest maps a play/evaluate request's index-addressed tiles to engine tile // records for the game's variant (a placed blank carries its designated letter's // index with Blank set). An out-of-range index surfaces as engine.ErrIllegalPlay (HTTP 400). func tilesFromRequest(variant engine.Variant, req submitPlayRequest) ([]engine.TileRecord, error) { tiles := make([]engine.TileRecord, 0, len(req.Tiles)) for _, t := range req.Tiles { letter, err := engine.LetterForIndex(variant, t.Letter) if err != nil { return nil, err } tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: letter, Blank: t.Blank}) } return tiles, nil } // handleSubmitPlay validates, scores and commits a placement. func (s *Server) handleSubmitPlay(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } gameID, ok := gameIDParam(c) if !ok { abortBadRequest(c, "invalid game id") 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 } res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, tiles) if err != nil { s.abortErr(c, err) return } s.writeMoveResult(c, res) } // handleGameState returns the player's view of a game. // handleHideGame hides a finished game from the caller's own lobby list. func (s *Server) handleHideGame(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } gameID, ok := gameIDParam(c) if !ok { abortBadRequest(c, "invalid game id") return } if err := s.games.HideGame(c.Request.Context(), uid, gameID); err != nil { s.abortErr(c, err) return } c.JSON(http.StatusOK, okResponse{OK: true}) } func (s *Server) handleGameState(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } gameID, ok := gameIDParam(c) if !ok { abortBadRequest(c, "invalid game id") return } view, err := s.games.GameState(c.Request.Context(), gameID, uid) if err != nil { s.abortErr(c, err) return } dto, err := stateDTOFrom(view, c.Query("include_alphabet") == "true") if err != nil { s.abortErr(c, err) return } s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) s.setUnreadChat(c.Request.Context(), &dto.Game, uid) c.JSON(http.StatusOK, dto) } // ensureVariantAllowed reports whether the caller's profile permits creating a game // of variant — it must be one of their VariantPreferences. It aborts the request // with 400 and returns false otherwise. Only the player who creates a game (quick // match, vs-AI or a friend invitation) is gated this way; an invited friend may // accept an invitation in any variant. func (s *Server) ensureVariantAllowed(c *gin.Context, uid uuid.UUID, variant string) bool { acc, err := s.accounts.GetByID(c.Request.Context(), uid) if err != nil { s.abortErr(c, err) return false } if !slices.Contains(acc.VariantPreferences, variant) { abortBadRequest(c, "variant is not in your preferences") return false } return true } // enqueueRequest enters per-variant auto-match under a per-turn word rule. VsAI true // starts an honest-AI game against a robot instead of the open/wait matchmaking path. type enqueueRequest struct { Variant string `json:"variant"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` VsAI bool `json:"vs_ai"` } // handleEnqueue enters the caller into a quick game and returns the game they land in // immediately. With vs_ai it starts an honest-AI game already seated with a robot; // otherwise it enters auto-match — a freshly opened game awaiting an opponent, or // another player's open game they just joined. The client navigates straight in. func (s *Server) handleEnqueue(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } var req enqueueRequest if err := c.ShouldBindJSON(&req); err != nil { abortBadRequest(c, "invalid request body") return } variant, err := engine.ParseVariant(req.Variant) if err != nil { abortBadRequest(c, "unknown variant") return } if !s.ensureVariantAllowed(c, uid, variant.String()) { return } kind := gamelimits.KindRandom enter := s.matchmaker.Enqueue if req.VsAI { kind = gamelimits.KindVsAI enter = s.matchmaker.StartVsAI } if !s.ensureUnderGameLimit(c, uid, kind) { return } res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn) if err != nil { s.abortErr(c, err) return } dto := matchDTOFrom(res) if dto.Game != nil { s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{}) } c.JSON(http.StatusOK, dto) } // chatPostRequest posts a per-game chat message. type chatPostRequest struct { Body string `json:"body"` } // handleChatPost stores a chat message from the authenticated player. The sender // IP is taken from the gateway-forwarded X-Forwarded-For header. func (s *Server) handleChatPost(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } gameID, ok := gameIDParam(c) if !ok { abortBadRequest(c, "invalid game id") return } var req chatPostRequest if err := c.ShouldBindJSON(&req); err != nil { abortBadRequest(c, "invalid request body") return } msg, err := s.social.PostMessage(c.Request.Context(), gameID, uid, req.Body, clientIP(c)) if err != nil { s.abortErr(c, err) return } c.JSON(http.StatusOK, chatDTOFrom(msg)) }