Stage 7 (wip): wire remaining ops (backend REST, FBS, gateway transcode) + real UI transport
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
This commit is contained in:
@@ -566,6 +566,13 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.
|
||||
return seats, g.ToMove, g.Status, nil
|
||||
}
|
||||
|
||||
// ListForAccount returns every game the account is seated in, newest first, for the
|
||||
// lobby's active/finished lists. The live position is not loaded — the summaries come
|
||||
// straight from the durable rows.
|
||||
func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
|
||||
return svc.store.ListGamesForAccount(ctx, accountID)
|
||||
}
|
||||
|
||||
// History returns a game's full, dictionary-independent move journal.
|
||||
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
|
||||
@@ -135,6 +135,50 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
|
||||
return projectGame(grow, srows)
|
||||
}
|
||||
|
||||
// ListGamesForAccount loads every game the account is seated in (active and
|
||||
// finished), newest first, each joined with its ordered seats. It backs the lobby's
|
||||
// "my games" lists.
|
||||
func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
|
||||
gstmt := postgres.SELECT(table.Games.AllColumns).
|
||||
FROM(table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID))).
|
||||
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))).
|
||||
ORDER_BY(table.Games.UpdatedAt.DESC())
|
||||
var grows []model.Games
|
||||
if err := gstmt.QueryContext(ctx, s.db, &grows); err != nil {
|
||||
return nil, fmt.Errorf("game: list for account: %w", err)
|
||||
}
|
||||
if len(grows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ids := make([]postgres.Expression, len(grows))
|
||||
for i, g := range grows {
|
||||
ids[i] = postgres.UUID(g.GameID)
|
||||
}
|
||||
sstmt := postgres.SELECT(table.GamePlayers.AllColumns).
|
||||
FROM(table.GamePlayers).
|
||||
WHERE(table.GamePlayers.GameID.IN(ids...)).
|
||||
ORDER_BY(table.GamePlayers.GameID.ASC(), table.GamePlayers.Seat.ASC())
|
||||
var srows []model.GamePlayers
|
||||
if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil {
|
||||
return nil, fmt.Errorf("game: list seats for account: %w", err)
|
||||
}
|
||||
byGame := make(map[uuid.UUID][]model.GamePlayers, len(grows))
|
||||
for _, r := range srows {
|
||||
byGame[r.GameID] = append(byGame[r.GameID], r)
|
||||
}
|
||||
|
||||
out := make([]Game, 0, len(grows))
|
||||
for _, g := range grows {
|
||||
pg, err := projectGame(g, byGame[g.GameID])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetJournal loads the ordered, decoded move journal for a game.
|
||||
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
|
||||
stmt := postgres.SELECT(table.GameMoves.AllColumns).
|
||||
|
||||
@@ -66,13 +66,15 @@ type moveRecordDTO struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// seatDTO is one seat's public standing.
|
||||
// seatDTO is one seat's public standing. DisplayName is resolved from the account
|
||||
// store by the handler (the game domain keys seats by account id only).
|
||||
type seatDTO struct {
|
||||
Seat int `json:"seat"`
|
||||
AccountID string `json:"account_id"`
|
||||
Score int `json:"score"`
|
||||
HintsUsed int `json:"hints_used"`
|
||||
IsWinner bool `json:"is_winner"`
|
||||
Seat int `json:"seat"`
|
||||
AccountID string `json:"account_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Score int `json:"score"`
|
||||
HintsUsed int `json:"hints_used"`
|
||||
IsWinner bool `json:"is_winner"`
|
||||
}
|
||||
|
||||
// gameDTO is the shared game summary.
|
||||
|
||||
@@ -37,8 +37,17 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/profile", s.handleProfile)
|
||||
}
|
||||
if s.games != nil {
|
||||
u.GET("/games", s.handleListGames)
|
||||
u.POST("/games/:id/play", s.handleSubmitPlay)
|
||||
u.GET("/games/:id/state", s.handleGameState)
|
||||
u.POST("/games/:id/pass", s.handlePass)
|
||||
u.POST("/games/:id/exchange", s.handleExchange)
|
||||
u.POST("/games/:id/resign", s.handleResign)
|
||||
u.POST("/games/:id/hint", s.handleHint)
|
||||
u.POST("/games/:id/evaluate", s.handleEvaluate)
|
||||
u.GET("/games/:id/check_word", s.handleCheckWord)
|
||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||
u.GET("/games/:id/history", s.handleHistory)
|
||||
}
|
||||
if s.matchmaker != nil {
|
||||
u.POST("/lobby/enqueue", s.handleEnqueue)
|
||||
@@ -46,6 +55,8 @@ func (s *Server) registerRoutes() {
|
||||
}
|
||||
if s.social != nil {
|
||||
u.POST("/games/:id/chat", s.handleChatPost)
|
||||
u.GET("/games/:id/chat", s.handleChatList)
|
||||
u.POST("/games/:id/nudge", s.handleNudge)
|
||||
}
|
||||
s.admin.GET("/ping", s.handleAdminPing)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
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)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, moveResultDTOFrom(res))
|
||||
s.writeMoveResult(c, res)
|
||||
}
|
||||
|
||||
// handleGameState returns the player's view of a game.
|
||||
@@ -88,7 +88,9 @@ func (s *Server) handleGameState(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stateDTOFrom(view))
|
||||
dto := stateDTOFrom(view)
|
||||
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// enqueueRequest joins the per-variant auto-match pool.
|
||||
@@ -118,7 +120,11 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, matchDTOFrom(res))
|
||||
dto := matchDTOFrom(res)
|
||||
if dto.Game != nil {
|
||||
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{})
|
||||
}
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// handlePoll reports whether the caller has been paired since queueing.
|
||||
@@ -133,7 +139,11 @@ func (s *Server) handlePoll(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, matchDTOFrom(res))
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user