Stage 17 round 6 (#4/#5/#6 backend): per-game draft store + conflict reset
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
Foundation for persisting a player's client-side composition: a game_drafts table (game_id, account_id, rack_order, board_tiles jsonb) with raw-SQL store/service methods — GetDraft/SaveDraft (seated-player check) and, on every committed move, clearing the actor's own draft and resetting any opponent's board draft whose cell the play overlapped (the draft can no longer be placed; the rack order is kept). Integration tests cover the round-trip, the actor clear, the overlap reset, a non-conflicting survival, and the outsider rejection. The gateway op slice + UI wiring follow.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user