06c8039281
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.
164 lines
5.0 KiB
Go
164 lines
5.0 KiB
Go
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
|
|
}
|