package server import ( "context" "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) // The handlers below extend the Stage 6 vertical slice with the remaining game and // chat operations the UI needs (PLAN.md Stage 7). 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"` } // evalResultDTO is an unlimited move preview: legality, score and the words formed. type evalResultDTO struct { Legal bool `json:"legal"` Score int `json:"score"` Words []string `json:"words"` } // 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. type gameListDTO struct { Games []gameDTO `json:"games"` } // chatListDTO is a game's chat history. type chatListDTO struct { Messages []chatDTO `json:"messages"` } // exchangeRequest swaps the given rack tiles back into the bag. type exchangeRequest struct { Tiles []string `json:"tiles"` } // complaintRequest disputes a word-check result. type complaintRequest struct { Word string `json:"word"` Note string `json:"note"` } // fillSeatNames resolves each seat's display name from the account store, memoising // across seats and games within one request. func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]string) { for i := range g.Seats { 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 } res, err := s.games.Exchange(c.Request.Context(), gameID, uid, req.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, }) } // 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 } dir, ok := parseDirection(req.Dir) if !ok { abortBadRequest(c, "dir must be H or V") return } tiles := make([]engine.TileRecord, 0, len(req.Tiles)) for _, t := range req.Tiles { tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}) } ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles) if err != nil { s.abortErr(c, err) return } c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words}) } // handleCheckWord looks a word up in the game's pinned dictionary. func (s *Server) handleCheckWord(c *gin.Context) { _, gameID, ok := s.userGame(c) if !ok { return } word := c.Query("word") 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}) } // 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}) } // handleListGames returns the caller's active and finished games for the lobby. func (s *Server) handleListGames(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } games, err := s.games.ListForAccount(c.Request.Context(), uid) if err != nil { s.abortErr(c, err) return } memo := map[string]string{} out := make([]gameDTO, 0, len(games)) for _, g := range games { dto := gameDTOFromGame(g) s.fillSeatNames(c.Request.Context(), &dto, memo) out = append(out, dto) } c.JSON(http.StatusOK, gameListDTO{Games: out}) } // 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)) } // 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 := moveResultDTOFrom(res) s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) c.JSON(http.StatusOK, dto) }