diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index c4104b5..45c5262 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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) diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index b3f58ce..5617b0b 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -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) diff --git a/backend/internal/server/handlers_test.go b/backend/internal/server/handlers_test.go index 4d9c8b1..d3257dd 100644 --- a/backend/internal/server/handlers_test.go +++ b/backend/internal/server/handlers_test.go @@ -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) + } +} diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 4320517..42299eb 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -2,6 +2,7 @@ package backendclient import ( "context" + "encoding/json" "net/http" "net/url" "strconv" @@ -337,6 +338,21 @@ func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultRes return out, err } +// GetDraft returns the player's saved composition for a game (Stage 17) as the backend's +// raw JSON body. The gateway forwards it verbatim, never interpreting its shape. +func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawMessage, error) { + var out json.RawMessage + err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/draft"), userID, "", nil, &out) + return out, err +} + +// SaveDraft upserts the player's composition for a game (Stage 17). body is the client's +// {rack_order, board_tiles} JSON, forwarded verbatim — a json.RawMessage marshals as-is, so +// there is no double-encode. +func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json.RawMessage) error { + return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil) +} + // Evaluate previews a tentative play's legality and score. The tiles are addressed by // alphabet index (Stage 13). func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) { diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 24d7281..06dd632 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -252,6 +252,17 @@ func encodeWordCheck(r backendclient.WordCheckResp) []byte { return b.FinishedBytes() } +// encodeDraftView builds a DraftView payload wrapping the player's composition JSON. The +// string is empty for the save acknowledgement (the client ignores that payload). +func encodeDraftView(jsonStr string) []byte { + b := flatbuffers.NewBuilder(256) + j := b.CreateString(jsonStr) + fb.DraftViewStart(b) + fb.DraftViewAddJson(b, j) + b.Finish(fb.DraftViewEnd(b)) + return b.FinishedBytes() +} + // encodeHistory builds a History payload (the decoded move journal). func encodeHistory(r backendclient.HistoryResp) []byte { b := flatbuffers.NewBuilder(1024) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 60a77f2..d8f23b1 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -7,6 +7,7 @@ package transcode import ( "context" + "encoding/json" "errors" "scrabble/gateway/internal/backendclient" @@ -38,6 +39,8 @@ const ( MsgGameHistory = "game.history" MsgChatList = "chat.list" MsgChatNudge = "chat.nudge" + MsgDraftGet = "draft.get" + MsgDraftSave = "draft.save" ) // Request is one decoded Execute call. @@ -108,6 +111,8 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true} r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true} r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true} + r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true} + r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true} registerStage8(r, backend) registerStage11(r, backend, tg, defaultLanguages) return r @@ -421,3 +426,28 @@ func nudgeHandler(backend *backendclient.Client) Handler { return encodeChat(res), nil } } + +// getDraftHandler returns the player's saved composition (Stage 17). It reuses +// GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView. +func getDraftHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsGameActionRequest(req.Payload, 0) + raw, err := backend.GetDraft(ctx, req.UserID, string(in.GameId())) + if err != nil { + return nil, err + } + return encodeDraftView(string(raw)), nil + } +} + +// saveDraftHandler upserts the player's composition (Stage 17), forwarding the opaque JSON +// string verbatim. It echoes an empty DraftView as a well-formed acknowledgement. +func saveDraftHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsDraftRequest(req.Payload, 0) + if err := backend.SaveDraft(ctx, req.UserID, string(in.GameId()), json.RawMessage(in.Json())); err != nil { + return nil, err + } + return encodeDraftView(""), nil + } +} diff --git a/gateway/internal/transcode/transcode_draft_test.go b/gateway/internal/transcode/transcode_draft_test.go new file mode 100644 index 0000000..7a9a70b --- /dev/null +++ b/gateway/internal/transcode/transcode_draft_test.go @@ -0,0 +1,82 @@ +package transcode_test + +import ( + "context" + "io" + "net/http" + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestDraftSaveForwardsRawJSON checks the save handler forwards the client's composition JSON +// to the backend verbatim (the "no double-encode" contract, Stage 17) with the user header. +func TestDraftSaveForwardsRawJSON(t *testing.T) { + const body = `{"rack_order":"1,0","board_tiles":[{"row":7,"col":7,"letter":"Q","blank":false}]}` + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/games/g-1/draft" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("X-User-ID"); got != "u-3" { + t.Errorf("X-User-ID = %q, want u-3", got) + } + raw, _ := io.ReadAll(r.Body) + if string(raw) != body { + t.Errorf("forwarded body = %q, want %q (verbatim)", raw, body) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgDraftSave) + if !ok { + t.Fatal("draft.save not registered") + } + + b := flatbuffers.NewBuilder(64) + gid := b.CreateString("g-1") + j := b.CreateString(body) + fb.DraftRequestStart(b) + fb.DraftRequestAddGameId(b, gid) + fb.DraftRequestAddJson(b, j) + b.Finish(fb.DraftRequestEnd(b)) + + if _, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-3"}); err != nil { + t.Fatalf("handler: %v", err) + } +} + +// TestDraftGetWrapsBackendJSON checks the get handler wraps the backend's stored draft JSON in +// a DraftView verbatim (the gateway never interprets the shape). +func TestDraftGetWrapsBackendJSON(t *testing.T) { + const stored = `{"rack_order":"2,0,1","board_tiles":[]}` + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/games/g-2/draft" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(stored)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgDraftGet) + + b := flatbuffers.NewBuilder(32) + gid := b.CreateString("g-2") + fb.GameActionRequestStart(b) + fb.GameActionRequestAddGameId(b, gid) + b.Finish(fb.GameActionRequestEnd(b)) + + payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-4"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + v := fb.GetRootAsDraftView(payload, 0) + if string(v.Json()) != stored { + t.Fatalf("DraftView.json = %q, want %q (verbatim)", v.Json(), stored) + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 23403f8..b1d8400 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -236,6 +236,20 @@ table HintResult { hints_remaining:int; } +// DraftRequest saves the player's client-side composition for a game (Stage 17): a single +// JSON string of {rack_order, board_tiles} the client serializes itself, so the wire carries +// no tile array. The gateway forwards json verbatim to the backend, which owns its shape. +table DraftRequest { + game_id:string; + json:string; +} + +// DraftView returns the player's stored composition as the same opaque JSON string (empty +// when none is stored). A draft get reuses GameActionRequest for its game id. +table DraftView { + json:string; +} + // History is a game's decoded move journal — the source for client board replay. table History { game_id:string; diff --git a/pkg/fbs/scrabblefb/DraftRequest.go b/pkg/fbs/scrabblefb/DraftRequest.go new file mode 100644 index 0000000..794c6f1 --- /dev/null +++ b/pkg/fbs/scrabblefb/DraftRequest.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type DraftRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsDraftRequest(buf []byte, offset flatbuffers.UOffsetT) *DraftRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &DraftRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishDraftRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsDraftRequest(buf []byte, offset flatbuffers.UOffsetT) *DraftRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &DraftRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedDraftRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *DraftRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *DraftRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *DraftRequest) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *DraftRequest) Json() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func DraftRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func DraftRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func DraftRequestAddJson(builder *flatbuffers.Builder, json flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(json), 0) +} +func DraftRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/DraftView.go b/pkg/fbs/scrabblefb/DraftView.go new file mode 100644 index 0000000..5831cf4 --- /dev/null +++ b/pkg/fbs/scrabblefb/DraftView.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type DraftView struct { + _tab flatbuffers.Table +} + +func GetRootAsDraftView(buf []byte, offset flatbuffers.UOffsetT) *DraftView { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &DraftView{} + x.Init(buf, n+offset) + return x +} + +func FinishDraftViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsDraftView(buf []byte, offset flatbuffers.UOffsetT) *DraftView { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &DraftView{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedDraftViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *DraftView) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *DraftView) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *DraftView) Json() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func DraftViewStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func DraftViewAddJson(builder *flatbuffers.Builder, json flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(json), 0) +} +func DraftViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index f6e77e4..511d7c2 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -29,6 +29,21 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page }) await expect(page.locator('.make')).toBeHidden(); }); +test('a placed tile is saved as a draft and restored on reopening the game', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + await page.waitForTimeout(600); // let the debounced draft save flush to the mock store + + // Leave the game and reopen it. The mock keeps the saved composition, so the pending tile is + // restored without re-placing it (Stage 17 #4/#6). + await page.evaluate(() => (location.hash = '/')); + await page.getByRole('button', { name: /Ann/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); +}); + test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 36db4ff..dd29acc 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -12,7 +12,7 @@ import { app, handleError, showToast } from '../lib/app.svelte'; import { GatewayError } from '../lib/client'; import { t } from '../lib/i18n/index.svelte'; - import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model'; + import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model'; import { replay } from '../lib/board'; import { centre, premiumGrid } from '../lib/premiums'; import { variantNameKey } from '../lib/variants'; @@ -33,6 +33,7 @@ toSubmit, type Placement, } from '../lib/placement'; + import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft'; let { id }: { id: string } = $props(); @@ -127,15 +128,43 @@ view = st; moves = hist.moves; setCachedGame(id, st, hist.moves); - placement = newPlacement(st.rack); - rackIds = st.rack.map((_, i) => i); - preview = null; selected = null; dirOverride = undefined; + await applyDraft(st); + recompute(); } catch (e) { handleError(e); } } + let draftSaveTimer: ReturnType | null = null; + // scheduleDraftSave persists the composition (rack order + pending tiles) after a short + // debounce; best-effort, so a failed save never interrupts play (Stage 17). + function scheduleDraftSave() { + if (draftSaveTimer) clearTimeout(draftSaveTimer); + draftSaveTimer = setTimeout(() => { + void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); + }, 500); + } + // applyDraft restores the player's saved composition over a freshly loaded state: the rack + // order (when still a valid permutation of the rack) and the board tiles whose cell is still + // free. Best-effort — a draft fetch never blocks opening the game. + async function applyDraft(st: StateView) { + let order = st.rack.map((_, i) => i); + let tiles: Tile[] = []; + try { + const parsed = parseDraft(await gateway.draftGet(id)); + if (parsed) { + order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order; + const committed = replay(moves); + tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]); + } + } catch { + /* best-effort */ + } + rackIds = order; + const rack = order.map((i) => st.rack[i]); + placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack); + } async function loadChat() { try { messages = await gateway.chatList(id); @@ -226,13 +255,16 @@ drag = null; } function onRackDown(e: PointerEvent, index: number) { - if (!isMyTurn || busy) return; + // Tiles may be arranged on the opponent's turn too (Stage 17 #5): only placement is + // relaxed — the preview and Make-move stay your-turn-only, so an off-turn draft is + // position-only (never scored or submitted). + if (busy || gameOver) return; beginDrag({ from: 'rack', index }, e); } // A pending tile can be dragged back to the rack, but only on the unzoomed board: when // zoomed the one-finger gesture scrolls the board, so recall there is via double-tap. function onBoardDown(e: PointerEvent, row: number, col: number) { - if (!isMyTurn || busy || zoomed) return; + if (busy || zoomed || gameOver) return; beginDrag({ from: 'board', row, col }, e); } function cellUnder(x: number, y: number): { row: number; col: number } | null { @@ -274,6 +306,7 @@ rackIds = order.map((i) => rackIds[i] ?? i); placement = newPlacement(order.map((i) => placement.rack[i])); selected = null; + scheduleDraftSave(); } function onWinMove(e: PointerEvent) { if (!downInfo) return; @@ -342,6 +375,7 @@ // Dropped a pending tile back onto the rack → recall it to its original slot. placement = recallAt(placement, di.src.row, di.src.col); recompute(); + scheduleDraftSave(); } swallowClick = true; setTimeout(() => (swallowClick = false), 60); @@ -359,6 +393,11 @@ window.removeEventListener('pointerdown', onExtraPointer); clearHover(); clearReorder(); + // Flush a pending draft save so leaving mid-composition still persists it (Stage 17). + if (draftSaveTimer) { + clearTimeout(draftSaveTimer); + void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); + } telegramClosingConfirmation(false); }); @@ -378,6 +417,7 @@ function onRecall(row: number, col: number) { placement = recallAt(placement, row, col); recompute(); + scheduleDraftSave(); } function attemptPlace(index: number, row: number, col: number) { if (board[row]?.[col]) return; @@ -391,6 +431,7 @@ placement = place(placement, index, row, col); telegramHaptic('select'); recompute(); + scheduleDraftSave(); } function chooseBlank(letter: string) { if (!blankPrompt) return; @@ -398,12 +439,15 @@ blankPrompt = null; telegramHaptic('select'); recompute(); + scheduleDraftSave(); } let previewTimer: ReturnType | null = null; function recompute() { preview = null; if (previewTimer) clearTimeout(previewTimer); + // Off-turn the composition is position-only: no score preview or evaluate (Stage 17 #5). + if (!isMyTurn) return; const sub = toSubmit(placement, dirOverride); if (!sub) return; previewTimer = setTimeout(async () => { @@ -436,6 +480,7 @@ preview = null; selected = null; dirOverride = undefined; + scheduleDraftSave(); } async function doPass() { @@ -507,6 +552,7 @@ setTimeout(() => (shuffling = false), 600); // A short "shake": a few quick light taps rather than one. for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55); + scheduleDraftSave(); } function openExchange() { resetPlacement(); @@ -729,7 +775,7 @@ /> {#if !gameOver && placement.pending.length > 0} - + {/if} {:else} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index b7cba9a..7c3f820 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -10,6 +10,8 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js'; export { CheckWordRequest } from './scrabblefb/check-word-request.js'; export { ComplaintRequest } from './scrabblefb/complaint-request.js'; export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js'; +export { DraftRequest } from './scrabblefb/draft-request.js'; +export { DraftView } from './scrabblefb/draft-view.js'; export { EmailLoginRequest } from './scrabblefb/email-login-request.js'; export { EmailRequestRequest } from './scrabblefb/email-request-request.js'; export { EnqueueRequest } from './scrabblefb/enqueue-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/draft-request.ts b/ui/src/gen/fbs/scrabblefb/draft-request.ts new file mode 100644 index 0000000..c9f2069 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/draft-request.ts @@ -0,0 +1,60 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class DraftRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):DraftRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsDraftRequest(bb:flatbuffers.ByteBuffer, obj?:DraftRequest):DraftRequest { + return (obj || new DraftRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsDraftRequest(bb:flatbuffers.ByteBuffer, obj?:DraftRequest):DraftRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new DraftRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +gameId():string|null +gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +gameId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +json():string|null +json(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +json(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startDraftRequest(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, gameIdOffset, 0); +} + +static addJson(builder:flatbuffers.Builder, jsonOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, jsonOffset, 0); +} + +static endDraftRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createDraftRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, jsonOffset:flatbuffers.Offset):flatbuffers.Offset { + DraftRequest.startDraftRequest(builder); + DraftRequest.addGameId(builder, gameIdOffset); + DraftRequest.addJson(builder, jsonOffset); + return DraftRequest.endDraftRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/draft-view.ts b/ui/src/gen/fbs/scrabblefb/draft-view.ts new file mode 100644 index 0000000..ca9f039 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/draft-view.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class DraftView { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):DraftView { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsDraftView(bb:flatbuffers.ByteBuffer, obj?:DraftView):DraftView { + return (obj || new DraftView()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsDraftView(bb:flatbuffers.ByteBuffer, obj?:DraftView):DraftView { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new DraftView()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +json():string|null +json(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +json(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startDraftView(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addJson(builder:flatbuffers.Builder, jsonOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, jsonOffset, 0); +} + +static endDraftView(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createDraftView(builder:flatbuffers.Builder, jsonOffset:flatbuffers.Offset):flatbuffers.Offset { + DraftView.startDraftView(builder); + DraftView.addJson(builder, jsonOffset); + return DraftView.endDraftView(builder); +} +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 80f3543..cb01b0e 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -83,6 +83,13 @@ export interface GatewayClient { checkWord(gameId: string, word: string, variant: Variant): Promise; complaint(gameId: string, word: string, note: string): Promise; + // --- draft (Stage 17) --- + /** The player's server-persisted client-side composition (rack order + board tiles), so a + * reload or a second device resumes the same arrangement. The JSON is opaque to the + * gateway; the client owns the {rack_order, board_tiles} shape. */ + draftGet(gameId: string): Promise; + draftSave(gameId: string, json: string): Promise; + // --- chat --- chatPost(gameId: string, body: string): Promise; chatList(gameId: string): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 788d04d..437120f 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import * as fb from '../gen/fbs/scrabblefb'; import { BLANK_INDEX, setAlphabet } from './alphabet'; import { + decodeDraftView, decodeFriendList, decodeGameList, decodeInvitation, @@ -11,6 +12,7 @@ import { decodeStateView, decodeStats, encodeCheckWord, + encodeDraftSave, encodeExchange, encodeStateRequest, encodeSubmitPlay, @@ -18,6 +20,20 @@ import { } from './codec'; describe('codec', () => { + it('round-trips a draft save request and view (Stage 17)', () => { + const json = '{"rack_order":"1,0","board_tiles":[]}'; + const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json))); + expect(req.gameId()).toBe('g1'); + expect(req.json()).toBe(json); + + const b = new Builder(64); + const j = b.createString('{"x":1}'); + fb.DraftView.startDraftView(b); + fb.DraftView.addJson(b, j); + b.finish(fb.DraftView.endDraftView(b)); + expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}'); + }); + it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => { setAlphabet('english', [ { index: 0, letter: 'a', value: 1 }, diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index cff9d81..ea1c51f 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -73,6 +73,18 @@ export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Ui return finish(b, fb.StateRequest.endStateRequest(b)); } +// encodeDraftSave wraps the player's composition JSON (Stage 17). The string is opaque on the +// wire — the gateway forwards it verbatim and only the client reads {rack_order, board_tiles}. +export function encodeDraftSave(gameId: string, json: string): Uint8Array { + const b = new Builder(256); + const gid = b.createString(gameId); + const j = b.createString(json); + fb.DraftRequest.startDraftRequest(b); + fb.DraftRequest.addGameId(b, gid); + fb.DraftRequest.addJson(b, j); + return finish(b, fb.DraftRequest.endDraftRequest(b)); +} + export function encodeSubmitPlay( gameId: string, dir: 'H' | 'V', @@ -359,6 +371,13 @@ export function decodeWordCheck(buf: Uint8Array): WordCheckResult { return { word: s(r.word()), legal: r.legal() }; } +// decodeDraftView returns the player's stored composition JSON (empty when none is stored or +// for the save acknowledgement); the caller parses {rack_order, board_tiles}. +export function decodeDraftView(buf: Uint8Array): string { + const v = fb.DraftView.getRootAsDraftView(new ByteBuffer(buf)); + return v.json() ?? ''; +} + export function decodeHistory(buf: Uint8Array): History { const h = fb.History.getRootAsHistory(new ByteBuffer(buf)); const moves: MoveRecord[] = []; diff --git a/ui/src/lib/draft.test.ts b/ui/src/lib/draft.test.ts new file mode 100644 index 0000000..aab8f16 --- /dev/null +++ b/ui/src/lib/draft.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from './draft'; +import type { PendingTile } from './placement'; + +describe('draft', () => { + it('round-trips the rack order and board tiles', () => { + const pending: PendingTile[] = [ + { rackIndex: 2, row: 7, col: 7, letter: 'Q', blank: false }, + { rackIndex: 0, row: 7, col: 8, letter: 'I', blank: true }, + ]; + const parsed = parseDraft(serializeDraft([3, 0, 1, 2], pending)); + expect(parsed).not.toBeNull(); + expect(parsed!.rackOrder).toEqual([3, 0, 1, 2]); + expect(parsed!.tiles).toEqual([ + { row: 7, col: 7, letter: 'Q', blank: false }, + { row: 7, col: 8, letter: 'I', blank: true }, + ]); + }); + + it('parses an empty or malformed draft as null', () => { + expect(parseDraft('')).toBeNull(); + expect(parseDraft('not json')).toBeNull(); + }); + + it('parses a draft with no rack order or tiles', () => { + expect(parseDraft(JSON.stringify({ rack_order: '', board_tiles: [] }))).toEqual({ rackOrder: [], tiles: [] }); + }); + + it('accepts a valid rack permutation and rejects a stale one', () => { + expect(validRackOrder([2, 0, 1], 3)).toEqual([2, 0, 1]); + expect(validRackOrder([0, 1], 3)).toBeNull(); // wrong length (the rack changed) + expect(validRackOrder([0, 0, 1], 3)).toBeNull(); // a duplicate, not a permutation + expect(validRackOrder([0, 1, 3], 3)).toBeNull(); // an index out of range + }); + + it('drops draft tiles whose cell is now occupied', () => { + const tiles = [ + { row: 7, col: 7, letter: 'A', blank: false }, + { row: 7, col: 8, letter: 'B', blank: false }, + ]; + expect(liveDraftTiles(tiles, (r, c) => r === 7 && c === 7)).toEqual([ + { row: 7, col: 8, letter: 'B', blank: false }, + ]); + }); +}); diff --git a/ui/src/lib/draft.ts b/ui/src/lib/draft.ts new file mode 100644 index 0000000..b2063ad --- /dev/null +++ b/ui/src/lib/draft.ts @@ -0,0 +1,59 @@ +// Draft (client-side composition) serialization, kept pure for unit tests. The server stores +// the JSON opaquely (Stage 17); only the client interprets {rack_order, board_tiles}. The rack +// order is a comma-joined permutation of the server rack's indices, in the player's visual +// order; the board tiles are the tiles laid but not yet submitted. + +import type { Tile } from './model'; +import type { PendingTile } from './placement'; + +interface DraftData { + rack_order: string; + board_tiles: Tile[]; +} + +/** serializeDraft builds the JSON to persist: the rack order and the pending board tiles. */ +export function serializeDraft(rackOrder: number[], pending: PendingTile[]): string { + const data: DraftData = { + rack_order: rackOrder.join(','), + board_tiles: pending.map((p) => ({ row: p.row, col: p.col, letter: p.letter, blank: p.blank })), + }; + return JSON.stringify(data); +} + +/** parseDraft decodes a stored draft, or null when empty or malformed. */ +export function parseDraft(json: string): { rackOrder: number[]; tiles: Tile[] } | null { + if (!json) return null; + try { + const d = JSON.parse(json) as Partial; + const rackOrder = String(d.rack_order ?? '') + .split(',') + .filter((s) => s !== '') + .map(Number); + const tiles = Array.isArray(d.board_tiles) ? d.board_tiles : []; + return { rackOrder, tiles }; + } catch { + return null; + } +} + +/** + * validRackOrder returns order when it is a permutation of [0, len), else null — so a stale + * order (the rack changed since the draft was saved) is ignored and the server order is kept. + */ +export function validRackOrder(order: number[], len: number): number[] | null { + if (order.length !== len) return null; + const seen = new Set(); + for (const i of order) { + if (!Number.isInteger(i) || i < 0 || i >= len || seen.has(i)) return null; + seen.add(i); + } + return order; +} + +/** + * liveDraftTiles drops saved tiles whose cell is now occupied on the committed board (the + * opponent has since played there) — the position-only reconcile after a refresh. + */ +export function liveDraftTiles(tiles: Tile[], occupied: (row: number, col: number) => boolean): Tile[] { + return tiles.filter((t) => !occupied(t.row, t.col)); +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 9de0d4d..80c8187 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -93,6 +93,7 @@ export class MockGateway implements GatewayClient { private blocks: AccountRef[] = []; private invitations: Invitation[] = mockInvitations(); private readonly stats: Stats = { ...MOCK_STATS }; + private readonly drafts = new Map(); constructor() { // Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the @@ -230,6 +231,7 @@ export class MockGateway implements GatewayClient { g.rack.push(...draw(variant, drawn)); g.bagLen -= drawn; g.view.toMove = (seat + 1) % g.view.players; + this.drafts.delete(gameId); this.scheduleOpponentReply(gameId); return { move: structuredClone(move), game: structuredClone(g.view) }; } @@ -263,6 +265,7 @@ export class MockGateway implements GatewayClient { }; g.moves.push(move); g.view.moveCount += 1; + this.drafts.delete(gameId); if (action === 'resign') { g.view.status = 'finished'; g.view.endReason = 'resignation'; @@ -319,6 +322,15 @@ export class MockGateway implements GatewayClient { } async complaint(): Promise {} + // --- draft (Stage 17): an in-memory composition store, so the reload/off-turn flow is + // exercised without a backend. A committed move clears the actor's own draft, as on the server. + async draftGet(gameId: string): Promise { + return this.drafts.get(gameId) ?? ''; + } + async draftSave(gameId: string, json: string): Promise { + this.drafts.set(gameId, json); + } + // --- chat --- async chatPost(gameId: string, body: string): Promise { const g = this.game(gameId); diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 23cec7a..607db57 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -114,6 +114,12 @@ export function createTransport(baseUrl: string): GatewayClient { async complaint(id, word, note) { await exec('game.complaint', codec.encodeComplaint(id, word, note)); }, + async draftGet(id) { + return codec.decodeDraftView(await exec('draft.get', codec.encodeGameAction(id))); + }, + async draftSave(id, json) { + await exec('draft.save', codec.encodeDraftSave(id, json)); + }, async chatPost(id, body) { return codec.decodeChatMessage(await exec('chat.post', codec.encodeChatPost(id, body)));