package game import ( "context" "database/sql" "errors" "fmt" "time" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" "scrabble/backend/internal/engine" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) // Store is the Postgres-backed query surface for games, seats, the move journal, // complaints and per-account statistics. type Store struct { db *sql.DB } // NewStore constructs a Store wrapping db. func NewStore(db *sql.DB) *Store { return &Store{db: db} } // gameInsert carries the immutable fields of a new game. type gameInsert struct { id uuid.UUID variant string dictVersion string seed int64 players int turnTimeoutSecs int hintsAllowed bool hintsPerPlayer int } // statDelta is one account's contribution to its statistics on a game finish. type statDelta struct { accountID uuid.UUID wins int losses int draws int gamePoints int wordPoints int } // commit is everything a single committed transition persists: the journal row, // the post-move game cursor and per-seat scores, and — when the move ended the // game — the finish stamp and the statistics deltas. type commit struct { gameID uuid.UUID seq int seat int action string score int runningTotal int exchanged []string rec engine.MoveRecord rackBefore []string toMove int turnStartedAt time.Time moveCount int scores []int now time.Time finished bool endReason string finishedAt time.Time winner int // -1 on a draw stats []statDelta } // activeGame is the sweeper's view of an in-progress game's turn clock. type activeGame struct { gameID uuid.UUID toMove int turnStartedAt time.Time turnTimeoutSecs int } // CreateGame inserts the games row and one game_players row per seat (seat 0 // first) inside a single transaction. func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error { return withTx(ctx, s.db, func(tx *sql.Tx) error { gi := table.Games.INSERT( table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer, ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer) if _, err := gi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert game: %w", err) } for seat, accountID := range seats { pi := table.GamePlayers.INSERT( table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID, ).VALUES(ins.id, seat, accountID) if _, err := pi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert seat %d: %w", seat, err) } } return nil }) } // GetGame loads the games row joined with its seats (ordered by seat), or // ErrNotFound. func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) { gstmt := postgres.SELECT(table.Games.AllColumns). FROM(table.Games). WHERE(table.Games.GameID.EQ(postgres.UUID(id))). LIMIT(1) var grow model.Games if err := gstmt.QueryContext(ctx, s.db, &grow); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Game{}, ErrNotFound } return Game{}, fmt.Errorf("game: get %s: %w", id, err) } sstmt := postgres.SELECT(table.GamePlayers.AllColumns). FROM(table.GamePlayers). WHERE(table.GamePlayers.GameID.EQ(postgres.UUID(id))). ORDER_BY(table.GamePlayers.Seat.ASC()) var srows []model.GamePlayers if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil { return Game{}, fmt.Errorf("game: get seats %s: %w", id, err) } return projectGame(grow, srows) } // GetJournal loads the ordered, decoded move journal for a game. func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) { stmt := postgres.SELECT(table.GameMoves.AllColumns). FROM(table.GameMoves). WHERE(table.GameMoves.GameID.EQ(postgres.UUID(id))). ORDER_BY(table.GameMoves.Seq.ASC()) var rows []model.GameMoves if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("game: get journal %s: %w", id, err) } out := make([]HistoryMove, 0, len(rows)) for _, r := range rows { p, err := parsePayload(r.Payload) if err != nil { return nil, err } out = append(out, HistoryMove{ Seq: int(r.Seq), Seat: int(r.Seat), Action: r.Action, Score: int(r.Score), RunningTotal: int(r.RunningTotal), Dir: p.Dir, MainRow: p.MainRow, MainCol: p.MainCol, Tiles: p.tileRecords(), Words: p.Words, Exchanged: p.Exchanged, Rack: p.Rack, }) } return out, nil } // CommitMove appends the move and applies the post-move game state — the turn // cursor and per-seat scores, plus the finish stamp and statistics when the move // ended the game — in one transaction. func (s *Store) CommitMove(ctx context.Context, c commit) error { payload, err := buildPayload(c.rec, c.rackBefore, c.exchanged).marshal() if err != nil { return err } return withTx(ctx, s.db, func(tx *sql.Tx) error { mi := table.GameMoves.INSERT( table.GameMoves.GameID, table.GameMoves.Seq, table.GameMoves.Seat, table.GameMoves.Action, table.GameMoves.Score, table.GameMoves.RunningTotal, table.GameMoves.ExchangedCount, table.GameMoves.Payload, ).VALUES(c.gameID, c.seq, c.seat, c.action, c.score, c.runningTotal, len(c.exchanged), payload) if _, err := mi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("append move: %w", err) } if c.finished { gu := table.Games.UPDATE( table.Games.Status, table.Games.ToMove, table.Games.MoveCount, table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt, ).SET( postgres.String(StatusFinished), postgres.Int(int64(c.toMove)), postgres.Int(int64(c.moveCount)), postgres.String(c.endReason), postgres.TimestampzT(c.now), postgres.TimestampzT(c.finishedAt), ).WHERE(table.Games.GameID.EQ(postgres.UUID(c.gameID))) if _, err := gu.ExecContext(ctx, tx); err != nil { return fmt.Errorf("finish game: %w", err) } } else { gu := table.Games.UPDATE( table.Games.ToMove, table.Games.TurnStartedAt, table.Games.MoveCount, table.Games.UpdatedAt, ).SET( postgres.Int(int64(c.toMove)), postgres.TimestampzT(c.turnStartedAt), postgres.Int(int64(c.moveCount)), postgres.TimestampzT(c.now), ).WHERE(table.Games.GameID.EQ(postgres.UUID(c.gameID))) if _, err := gu.ExecContext(ctx, tx); err != nil { return fmt.Errorf("advance game: %w", err) } } for seat, score := range c.scores { if err := updateSeatScore(ctx, tx, c.gameID, seat, score, c.finished, c.finished && seat == c.winner); err != nil { return fmt.Errorf("update seat %d: %w", seat, err) } } if c.finished { for _, d := range c.stats { if err := upsertStats(ctx, tx, d, c.now); err != nil { return err } } } return nil }) } // updateSeatScore writes a seat's running score, also stamping is_winner when the // game has finished. func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error { where := table.GamePlayers.GameID.EQ(postgres.UUID(gameID)). AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))) var stmt postgres.UpdateStatement if finished { stmt = table.GamePlayers. UPDATE(table.GamePlayers.Score, table.GamePlayers.IsWinner). SET(postgres.Int(int64(score)), postgres.Bool(isWinner)). WHERE(where) } else { stmt = table.GamePlayers. UPDATE(table.GamePlayers.Score). SET(postgres.Int(int64(score))). WHERE(where) } _, err := stmt.ExecContext(ctx, tx) return err } // upsertStats folds one account's deltas into account_stats, locking the row for // the read-modify-write so concurrent finishes accumulate correctly. func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error { ensure := table.AccountStats. INSERT(table.AccountStats.AccountID). VALUES(d.accountID). ON_CONFLICT(table.AccountStats.AccountID). DO_NOTHING() if _, err := ensure.ExecContext(ctx, tx); err != nil { return fmt.Errorf("ensure stats %s: %w", d.accountID, err) } sel := postgres.SELECT(table.AccountStats.AllColumns). FROM(table.AccountStats). WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID))). FOR(postgres.UPDATE()) var row model.AccountStats if err := sel.QueryContext(ctx, tx, &row); err != nil { return fmt.Errorf("lock stats %s: %w", d.accountID, err) } wins := row.Wins + int32(d.wins) losses := row.Losses + int32(d.losses) draws := row.Draws + int32(d.draws) maxGame := max(row.MaxGamePoints, int32(d.gamePoints)) maxWord := max(row.MaxWordPoints, int32(d.wordPoints)) upd := table.AccountStats.UPDATE( table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws, table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt, ).SET( postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)), postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now), ).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID))) if _, err := upd.ExecContext(ctx, tx); err != nil { return fmt.Errorf("update stats %s: %w", d.accountID, err) } return nil } // SpendHintAllowance increments a seat's per-game hint counter by one. func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error { stmt := table.GamePlayers. UPDATE(table.GamePlayers.HintsUsed). SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))). WHERE( table.GamePlayers.GameID.EQ(postgres.UUID(gameID)). AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))), ) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("game: spend hint allowance: %w", err) } return nil } // FileComplaint persists a word-check complaint in status open and returns the // stored row. func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, error) { id, err := uuid.NewV7() if err != nil { return Complaint{}, fmt.Errorf("game: new complaint id: %w", err) } stmt := table.Complaints.INSERT( table.Complaints.ComplaintID, table.Complaints.ComplainantID, table.Complaints.GameID, table.Complaints.Variant, table.Complaints.DictVersion, table.Complaints.Word, table.Complaints.WasValid, table.Complaints.Note, ).VALUES( id, c.ComplainantID, c.GameID, c.Variant.String(), c.DictVersion, c.Word, c.WasValid, c.Note, ).RETURNING(table.Complaints.AllColumns) var row model.Complaints if err := stmt.QueryContext(ctx, s.db, &row); err != nil { return Complaint{}, fmt.Errorf("game: file complaint: %w", err) } return projectComplaint(row) } // ActiveGames returns the turn clocks of every in-progress game; the sweeper // filters them against the per-move deadline and the player's away window. func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) { stmt := postgres.SELECT( table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, table.Games.TurnTimeoutSecs, ).FROM(table.Games). WHERE(table.Games.Status.EQ(postgres.String(StatusActive))). ORDER_BY(table.Games.TurnStartedAt.ASC()) var rows []model.Games if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("game: list active: %w", err) } out := make([]activeGame, 0, len(rows)) for _, r := range rows { out = append(out, activeGame{ gameID: r.GameID, toMove: int(r.ToMove), turnStartedAt: r.TurnStartedAt, turnTimeoutSecs: int(r.TurnTimeoutSecs), }) } return out, nil } // GameSeed returns the bag seed a game was dealt from, used to replay it. The // seed is server-only state and never travels in the public Game view. func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { stmt := postgres.SELECT(table.Games.Seed). FROM(table.Games). WHERE(table.Games.GameID.EQ(postgres.UUID(id))). LIMIT(1) var row model.Games if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return 0, ErrNotFound } return 0, fmt.Errorf("game: get seed %s: %w", id, err) } return row.Seed, nil } // projectGame builds a Game from a games row and its ordered seat rows. func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { variant, err := engine.ParseVariant(g.Variant) if err != nil { return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err) } out := Game{ ID: g.GameID, Variant: variant, DictVersion: g.DictVersion, Status: g.Status, Players: int(g.Players), ToMove: int(g.ToMove), TurnStartedAt: g.TurnStartedAt, TurnTimeout: time.Duration(g.TurnTimeoutSecs) * time.Second, HintsAllowed: g.HintsAllowed, HintsPerPlayer: int(g.HintsPerPlayer), MoveCount: int(g.MoveCount), CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } if g.EndReason != nil { out.EndReason = *g.EndReason } if g.FinishedAt != nil { t := *g.FinishedAt out.FinishedAt = &t } out.Seats = make([]Seat, 0, len(seats)) for _, p := range seats { out.Seats = append(out.Seats, Seat{ Seat: int(p.Seat), AccountID: p.AccountID, Score: int(p.Score), HintsUsed: int(p.HintsUsed), IsWinner: p.IsWinner, }) } return out, nil } // projectComplaint builds a Complaint from a stored row. func projectComplaint(row model.Complaints) (Complaint, error) { variant, err := engine.ParseVariant(row.Variant) if err != nil { return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err) } return Complaint{ ID: row.ComplaintID, ComplainantID: row.ComplainantID, GameID: row.GameID, Variant: variant, DictVersion: row.DictVersion, Word: row.Word, WasValid: row.WasValid, Note: row.Note, Status: row.Status, CreatedAt: row.CreatedAt, }, nil } // withTx wraps fn in a transaction, committing on nil and rolling back on error. func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } if err := fn(tx); err != nil { _ = tx.Rollback() return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit tx: %w", err) } return nil }