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 }