f5c2404123
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Complete the client-side draft feature on top of the shipped backend
foundation (the game_drafts store/service):
- FB: DraftRequest{game_id,json} + DraftView{json} (a draft get reuses
GameActionRequest); regenerated committed Go + TS bindings.
- Backend REST: GET/PUT /games/:id/draft, a draftDTO
(rack_order/board_tiles) mapped to game.Draft.
- Gateway: draft.get/draft.save transcode forwarding the composition
JSON verbatim (json.RawMessage both ways -- no double-encode).
- UI: debounced save of the rack order + board tiles and restore on
load (lib/draft.ts), plus #5 -- tiles may be arranged on the
opponent's turn (placement relaxed; the preview and Make-move stay
your-turn-only, so an off-turn draft is position-only).
Tests: backend handler validation, gateway pass-through round-trip, UI
draft/codec units, and a draft-restore e2e.
465 lines
12 KiB
Go
465 lines
12 KiB
Go
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)
|
|
}
|