65689b903f
backend: REST handlers for pass/exchange/resign/hint/evaluate/check_word/complaint/history/chat-list/nudge + new game.ListForAccount (my games) + seat display_name resolution pkg/fbs: GameActionRequest/ExchangeRequest/EvalRequest/EvalResult/CheckWordRequest/WordCheckResult/ComplaintRequest/HintResult/History/GameList/ChatList + SeatView.display_name; committed Go regenerated (flatc 23.5.26) gateway: 11 new transcode ops + backendclient methods + FB encoders ui: edge TS codegen (flatc --ts + protoc-gen-es, committed), FlatBuffers<->model codec, real connect-web transport (binary, bearer auth, Subscribe). prod bundle ~69KB gzip JS
322 lines
8.4 KiB
Go
322 lines
8.4 KiB
Go
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)
|
|
}
|