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 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. Tiles are wire alphabet // indices (Stage 13); 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 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 } 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, }) } // 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 } 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, 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. The word arrives as // repeated ?idx= alphabet indices (Stage 13); 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 (Stage 13). 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 (Stage 17): 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 (Stage 17), 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 (Stage 17). 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}) } // 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) }