diff --git a/backend/internal/game/draft.go b/backend/internal/game/draft.go new file mode 100644 index 0000000..9114b86 --- /dev/null +++ b/backend/internal/game/draft.go @@ -0,0 +1,163 @@ +package game + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" +) + +// DraftTile is one tile a player has laid on the board but not yet submitted. +type DraftTile struct { + Row int `json:"row"` + Col int `json:"col"` + Letter string `json:"letter"` + Blank bool `json:"blank"` +} + +// Draft is a player's persisted client-side composition for a game (Stage 17): the +// preferred rack tile order and the board tiles laid but not yet submitted. The server +// keeps it so a reload or a second device resumes the same arrangement. +type Draft struct { + RackOrder string + BoardTiles []DraftTile +} + +// GetDraft returns the player's draft for a game, or a zero Draft when none is stored. +func (svc *Service) GetDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) { + return svc.store.getDraft(ctx, gameID, accountID) +} + +// SaveDraft upserts the player's draft; the account must be seated in the game. +func (svc *Service) SaveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error { + seats, _, _, err := svc.Participants(ctx, gameID) + if err != nil { + return err + } + if !slices.Contains(seats, accountID) { + return ErrNotAPlayer + } + return svc.store.saveDraft(ctx, gameID, accountID, d) +} + +// getDraft reads one draft row, returning a zero Draft when absent. +func (s *Store) getDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) { + var rackOrder string + var boardJSON []byte + err := s.db.QueryRowContext(ctx, + `SELECT rack_order, board_tiles FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`, + gameID, accountID).Scan(&rackOrder, &boardJSON) + if errors.Is(err, sql.ErrNoRows) { + return Draft{}, nil + } + if err != nil { + return Draft{}, fmt.Errorf("game: get draft %s: %w", gameID, err) + } + d := Draft{RackOrder: rackOrder} + if len(boardJSON) > 0 { + if err := json.Unmarshal(boardJSON, &d.BoardTiles); err != nil { + return Draft{}, fmt.Errorf("game: decode draft tiles: %w", err) + } + } + return d, nil +} + +// saveDraft upserts the player's draft. +func (s *Store) saveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error { + tiles := d.BoardTiles + if tiles == nil { + tiles = []DraftTile{} + } + boardJSON, err := json.Marshal(tiles) + if err != nil { + return fmt.Errorf("game: encode draft tiles: %w", err) + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO backend.game_drafts (game_id, account_id, rack_order, board_tiles, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (game_id, account_id) + DO UPDATE SET rack_order = $3, board_tiles = $4, updated_at = now()`, + gameID, accountID, d.RackOrder, boardJSON); err != nil { + return fmt.Errorf("game: save draft: %w", err) + } + return nil +} + +// clearDraft drops a player's draft row (their composition is consumed or discarded). +func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) error { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`, + gameID, accountID); err != nil { + return fmt.Errorf("game: clear draft: %w", err) + } + return nil +} + +// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has +// a tile on one of the just-committed cells, since that draft can no longer be placed; the +// rack order is kept (Stage 17 #6). +func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error { + if len(cells) == 0 { + return nil + } + occupied := make(map[[2]int]bool, len(cells)) + for _, c := range cells { + occupied[[2]int{c.Row, c.Col}] = true + } + rows, err := s.db.QueryContext(ctx, + `SELECT account_id, board_tiles FROM backend.game_drafts + WHERE game_id = $1 AND account_id <> $2 AND board_tiles <> '[]'::jsonb`, + gameID, actorID) + if err != nil { + return fmt.Errorf("game: scan drafts for conflict: %w", err) + } + var toClear []uuid.UUID + func() { + defer func() { _ = rows.Close() }() + for rows.Next() { + var acc uuid.UUID + var boardJSON []byte + if err = rows.Scan(&acc, &boardJSON); err != nil { + return + } + var tiles []DraftTile + if json.Unmarshal(boardJSON, &tiles) != nil { + continue // skip a malformed draft + } + for _, t := range tiles { + if occupied[[2]int{t.Row, t.Col}] { + toClear = append(toClear, acc) + break + } + } + } + err = rows.Err() + }() + if err != nil { + return fmt.Errorf("game: read drafts for conflict: %w", err) + } + for _, acc := range toClear { + if _, err := s.db.ExecContext(ctx, + `UPDATE backend.game_drafts SET board_tiles = '[]'::jsonb, updated_at = now() + WHERE game_id = $1 AND account_id = $2`, + gameID, acc); err != nil { + return fmt.Errorf("game: clear conflicting draft: %w", err) + } + } + return nil +} + +// draftTilesFrom projects a play's committed tiles into draft cells, for the conflict scan. +func draftTilesFrom(rec engine.MoveRecord) []DraftTile { + out := make([]DraftTile, 0, len(rec.Tiles)) + for _, t := range rec.Tiles { + out = append(out, DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}) + } + return out +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 0010f29..d57eddf 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -208,6 +208,7 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo if err != nil { return MoveResult{}, err } + svc.afterCommitDrafts(ctx, gameID, accountID, rec) // A resignation carries no think time (it can happen on the opponent's turn), so it // is intentionally excluded from the move-duration metric. return MoveResult{Move: rec, Game: post}, nil @@ -274,12 +275,28 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, if err != nil { return MoveResult{}, err } + svc.afterCommitDrafts(ctx, gameID, accountID, rec) // Record the seat's think time (turn start to commit) for the move-duration // metric; the timeout path commits separately and is excluded by design. svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt)) return MoveResult{Move: rec, Game: post}, nil } +// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own +// composition is consumed, so clear it; a play's tiles may overlap an opponent's board +// draft, which is then reset. Best-effort — the move is already committed, so a draft +// cleanup failure is logged rather than failing the move. +func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uuid.UUID, rec engine.MoveRecord) { + if err := svc.store.clearDraft(ctx, gameID, accountID); err != nil { + svc.log.Warn("clear actor draft", zap.Error(err)) + } + if rec.Action == engine.ActionPlay { + if err := svc.store.resetConflictingBoardDrafts(ctx, gameID, accountID, draftTilesFrom(rec)); err != nil { + svc.log.Warn("reset conflicting board drafts", zap.Error(err)) + } + } +} + // commit persists a just-applied transition: the journal row, the post-move turn // cursor and scores, and on a game-ending move the finish stamp and statistics. // On a persistence failure it evicts the now-divergent live game so the next diff --git a/backend/internal/inttest/draft_test.go b/backend/internal/inttest/draft_test.go new file mode 100644 index 0000000..41bad24 --- /dev/null +++ b/backend/internal/inttest/draft_test.go @@ -0,0 +1,104 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// newDraftGame creates a started two-player English game on an opening seed and returns the +// service, game id, seats, and the opening play (from a mirror) used to drive a real commit. +func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) { + t.Helper() + ctx := context.Background() + svc := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + seed := openingSeed(t) + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + hint, ok := newMirror(t, seed, 2).HintView() + if !ok || len(hint.Tiles) == 0 { + t.Fatal("no opening move") + } + return svc, g.ID, seats, hint +} + +// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the +// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's +// board draft reset when a committed play overlaps one of its cells (the rack order kept). +func TestDraftPersistAndConflictReset(t *testing.T) { + ctx := context.Background() + svc, gameID, seats, hint := newDraftGame(t) + + // Round-trip seat 0's rack order + a board draft. + d0 := game.Draft{RackOrder: "QANIWE?", BoardTiles: []game.DraftTile{{Row: 1, Col: 1, Letter: "Q"}}} + if err := svc.SaveDraft(ctx, gameID, seats[0], d0); err != nil { + t.Fatalf("save draft 0: %v", err) + } + if got, err := svc.GetDraft(ctx, gameID, seats[0]); err != nil || + got.RackOrder != "QANIWE?" || len(got.BoardTiles) != 1 || got.BoardTiles[0].Letter != "Q" { + t.Fatalf("get draft 0 = %+v (err %v)", got, err) + } + + // Seat 1 drafts a board tile on a cell the opening play will commit. + overlap := hint.Tiles[0] + if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{ + RackOrder: "ABCDEFG", + BoardTiles: []game.DraftTile{{Row: overlap.Row, Col: overlap.Col, Letter: "X"}}, + }); err != nil { + t.Fatalf("save draft 1: %v", err) + } + + if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil { + t.Fatalf("seat0 play: %v", err) + } + + // Seat 0's own draft is cleared by their move. + if d, _ := svc.GetDraft(ctx, gameID, seats[0]); d.RackOrder != "" || len(d.BoardTiles) != 0 { + t.Errorf("actor draft not cleared: %+v", d) + } + // Seat 1's board draft overlapped the play and is reset; the rack order is kept. + if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 0 || d.RackOrder != "ABCDEFG" { + t.Errorf("conflicting draft not reset (or rack order lost): %+v", d) + } +} + +// TestDraftSurvivesNonConflictingMove checks an opponent's board draft is kept when a +// committed play does not touch any of its cells. +func TestDraftSurvivesNonConflictingMove(t *testing.T) { + ctx := context.Background() + svc, gameID, seats, hint := newDraftGame(t) + + // Seat 1 drafts a far corner tile the central opening play cannot reach. + if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{ + BoardTiles: []game.DraftTile{{Row: 0, Col: 0, Letter: "Z"}}, + }); err != nil { + t.Fatalf("save draft 1: %v", err) + } + if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil { + t.Fatalf("seat0 play: %v", err) + } + if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" { + t.Errorf("non-conflicting draft should survive: %+v", d) + } +} + +// TestSaveDraftRejectsOutsider checks only a seated player may save a draft. +func TestSaveDraftRejectsOutsider(t *testing.T) { + ctx := context.Background() + svc, gameID, _, _ := newDraftGame(t) + if err := svc.SaveDraft(ctx, gameID, provisionAccount(t), game.Draft{RackOrder: "X"}); err == nil { + t.Fatal("outsider SaveDraft should fail") + } +} diff --git a/backend/internal/postgres/migrations/00011_game_drafts.sql b/backend/internal/postgres/migrations/00011_game_drafts.sql new file mode 100644 index 0000000..b6bf77c --- /dev/null +++ b/backend/internal/postgres/migrations/00011_game_drafts.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- Stage 17: a per-(game, account) draft the server persists across reloads and devices — +-- the player's preferred rack tile order (#4) and the tiles they have laid on the board but +-- not yet submitted (#5/#6). board_tiles is reset when an opponent's committed move overlaps +-- one of its cells (the draft can no longer be placed). Queried with raw SQL, so no +-- generated jet code is needed. +SET search_path = backend, pg_catalog; + +CREATE TABLE game_drafts ( + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + rack_order text NOT NULL DEFAULT '', + board_tiles jsonb NOT NULL DEFAULT '[]', + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (game_id, account_id) +); + +-- +goose Down +SET search_path = backend, pg_catalog; + +DROP TABLE game_drafts;