Stage 17 round 6 (#4/#5/#6): draft persistence wire + gateway + UI
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
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.
This commit is contained in:
@@ -66,6 +66,8 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||
u.GET("/games/:id/history", s.handleHistory)
|
||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||
}
|
||||
if s.matchmaker != nil {
|
||||
u.POST("/lobby/enqueue", s.handleEnqueue)
|
||||
|
||||
@@ -318,6 +318,74 @@ func (s *Server) handleExportGCG(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -73,3 +73,28 @@ func TestSubmitPlayRejectsBadGameID(t *testing.T) {
|
||||
t.Fatalf("submit play bad game id = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDraftRequiresUserID(t *testing.T) {
|
||||
path := "/api/v1/user/games/" + uuid.New().String() + "/draft"
|
||||
rec := do(t, newRoutingServer(), http.MethodGet, path, "", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("get draft without X-User-ID = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDraftRejectsBadGameID(t *testing.T) {
|
||||
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||
rec := do(t, newRoutingServer(), http.MethodPut, "/api/v1/user/games/not-a-uuid/draft", `{"rack_order":"","board_tiles":[]}`, headers)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("save draft bad game id = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDraftRejectsBadBody(t *testing.T) {
|
||||
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||
path := "/api/v1/user/games/" + uuid.New().String() + "/draft"
|
||||
rec := do(t, newRoutingServer(), http.MethodPut, path, `not json`, headers)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("save draft bad body = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user