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:
Ilia Denisov
2026-06-03 00:49:07 +02:00
parent 453ddc5e94
commit 65689b903f
64 changed files with 5151 additions and 52 deletions
+7
View File
@@ -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)
+44
View File
@@ -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).
+8 -6
View File
@@ -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.
+11
View File
@@ -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)
}
+321
View File
@@ -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)
}
+14 -4
View File
@@ -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.