Files
scrabble-game/backend/internal/game/draft.go
T
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00

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: 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.
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
}