package game import ( "context" "database/sql" "encoding/json" "errors" "fmt" "hash/fnv" "time" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" "scrabble/backend/internal/account" "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 dropoutTiles string // multipleWordsPerTurn false selects the single-word rule for the game. multipleWordsPerTurn bool // vsAI marks an honest-AI game (games.vs_ai). vsAI bool // status is the lifecycle state to create the game in: StatusActive for a normal // seated game, StatusOpen for an auto-match game still awaiting an opponent. An // empty string defaults to StatusActive. status string // openDeadline, set only for a StatusOpen game, is when the matchmaking reaper // substitutes a robot if no human has joined; nil for a normal game. openDeadline *time.Time } // statDelta is one account's contribution to its statistics on a game finish. // bestVariant/bestScore/bestTiles describe the game's best play for this account when // it scored (bestVariant empty otherwise): the variant label, the play's total score and // its main word as rendering tiles. They feed the per-variant account_best_move upsert, // which keeps only the account's highest-scoring play per variant. type statDelta struct { accountID uuid.UUID wins int losses int draws int gamePoints int wordPoints int moves int // plays this game (tile placements), summed into account_stats.moves hintsUsed int // hints used this game (allowance + wallet), summed into account_stats.hints_used bestVariant string bestScore int bestTiles []account.BestMoveTile } // 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 } // voidCommit is everything voiding an unreplayable game persists: the finish stamp with its // end reason, each seat's partial score as a draw, and the draw statistics. It appends no // journal row and leaves the move cursor untouched, so the journal is preserved. type voidCommit struct { gameID uuid.UUID endReason string scores []int now time.Time 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 } // seatInsert is one seat to create: the account to seat (uuid.Nil for the still-empty // opponent seat of an open auto-match game) and the display-name snapshot to stamp on // it — the player's name as of when the seat was taken (account.go, docs/ARCHITECTURE.md §7). type seatInsert struct { accountID uuid.UUID displayName string } // 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 []seatInsert) error { return withTx(ctx, s.db, func(tx *sql.Tx) error { return insertGameTx(ctx, tx, ins, seats) }) } // insertGameTx inserts the games row and one game_players row per seat (seat 0 // first) on tx, stamping each seat's display-name snapshot. A seat whose account id is // uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty // opponent seat of a StatusOpen auto-match game. func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatInsert) error { status := ins.status if status == "" { status = StatusActive } var deadline any = postgres.NULL if ins.openDeadline != nil { deadline = postgres.TimestampzT(*ins.openDeadline) } gi := table.Games.INSERT( table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI) if _, err := gi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert game: %w", err) } for seat, si := range seats { var acc any = si.accountID if si.accountID == uuid.Nil { acc = postgres.NULL } pi := table.GamePlayers.INSERT( table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID, table.GamePlayers.DisplayName, ).VALUES(ins.id, seat, acc, si.displayName) if _, err := pi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert seat %d: %w", seat, err) } } return nil } // openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the // advisory-lock key that serialises concurrent enqueues for that bucket, so two // players never both open a game instead of pairing. func openMatchKey(variant string, multipleWords bool) int64 { h := fnv.New64a() _, _ = h.Write([]byte(variant)) if multipleWords { _, _ = h.Write([]byte{1}) } else { _, _ = h.Write([]byte{0}) } return int64(h.Sum64()) } // OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it // lands in: it joins another player's waiting open game and flips it active // (joined=true), or opens a fresh game seating the caller with an empty opponent seat // (created=true). A re-enqueue while the caller already has an open game in the bucket // opens another fresh game (or joins a different player's) rather than returning the // caller's own, so tapping "random opponent" again always starts a new search. ins // supplies the new game's immutable fields and is used only when a game is created. A // transaction-scoped advisory lock on the (variant, rule) bucket serialises concurrent // enqueues so two callers pair rather than each opening a game. seats is the two-seat // arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen // order) used only when a game is created; callerName is the caller's display-name // snapshot, stamped on their seat whether they open a fresh game or fill another // player's open one. func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) { err = withTx(ctx, s.db, func(tx *sql.Tx) error { if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil { return fmt.Errorf("open match lock: %w", e) } // 1. Another player's open game waiting for an opponent — fill its seat and start it. var other uuid.UUID switch e := tx.QueryRowContext(ctx, `SELECT g.game_id FROM backend.games g WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND NOT EXISTS (SELECT 1 FROM backend.game_players p WHERE p.game_id = g.game_id AND p.account_id = $3) ORDER BY g.created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); { case e == nil: if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil { return er } gameID, joined = other, true return nil case !errors.Is(e, sql.ErrNoRows): return fmt.Errorf("find open game: %w", e) } // 2. None waiting — open a fresh game seating the caller (the other seat empty). if e := insertGameTx(ctx, tx, ins, seats); e != nil { return e } gameID, created = ins.id, true return nil }) return gameID, joined, created, err } // AttachRobot fills the empty opponent seat of open game gameID with robotID, stamping // displayName (the robot's per-game name) on the seat, and flips it to active, returning // whether it attached. It is a no-op (false) when the game is no longer open — a human // joined first — so the reaper never double-fills. func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (bool, error) { attached := false err := withTx(ctx, s.db, func(tx *sql.Tx) error { var status string switch e := tx.QueryRowContext(ctx, `SELECT status FROM backend.games WHERE game_id = $1 FOR UPDATE`, gameID).Scan(&status); { case errors.Is(e, sql.ErrNoRows): return nil case e != nil: return fmt.Errorf("lock game for robot: %w", e) } if status != StatusOpen { return nil } if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil { return e } attached = true return nil }) return attached, err } // fillOpenSeat seats accountID in an open game's empty opponent seat — stamping // displayName as the seat's display-name snapshot — and flips the game to active with a // fresh turn clock. The caller holds the game row. func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error { if _, err := tx.ExecContext(ctx, `UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`, gameID, accountID, displayName); err != nil { return fmt.Errorf("fill opponent seat: %w", err) } if _, err := tx.ExecContext(ctx, `UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now() WHERE game_id = $1`, gameID); err != nil { return fmt.Errorf("activate game: %w", err) } return nil } // ExpiredOpen returns the open games whose robot deadline has passed (at or before // now), oldest deadline first, for the matchmaking reaper to fill with a robot. func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) { rows, err := s.db.QueryContext(ctx, `SELECT game_id, variant FROM backend.games WHERE status = 'open' AND open_deadline_at IS NOT NULL AND open_deadline_at <= $1 ORDER BY open_deadline_at`, now) if err != nil { return nil, fmt.Errorf("game: expired open: %w", err) } defer rows.Close() var out []OpenGame for rows.Next() { var id uuid.UUID var variantStr string if err := rows.Scan(&id, &variantStr); err != nil { return nil, fmt.Errorf("game: scan expired open: %w", err) } variant, err := engine.ParseVariant(variantStr) if err != nil { return nil, fmt.Errorf("game: expired open %s: %w", id, err) } out = append(out, OpenGame{ID: id, Variant: variant}) } return out, rows.Err() } // 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) } // GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses // to map wire alphabet indices to concrete letters without loading the whole // game and its seats. func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) { stmt := postgres.SELECT(table.Games.Variant). 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 variant %s: %w", id, err) } return engine.ParseVariant(row.Variant) } // SharedGameExists reports whether accounts a and b are both seated in at least // one game (active or finished). It backs the social package's "befriend an // opponent" gate via a self-join on game_players. func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, error) { other := table.GamePlayers.AS("other") stmt := postgres.SELECT(table.GamePlayers.GameID). FROM(table.GamePlayers.INNER_JOIN(other, other.GameID.EQ(table.GamePlayers.GameID))). WHERE( table.GamePlayers.AccountID.EQ(postgres.UUID(a)). AND(other.AccountID.EQ(postgres.UUID(b))), ).LIMIT(1) var rows []model.GamePlayers if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return false, fmt.Errorf("game: shared game exists: %w", err) } return len(rows) > 0, nil } // DeleteOpenGame removes a game only while it is still open (an auto-match game awaiting an // opponent), reporting whether a row was deleted. The starter's lone seat and any draft cascade // away with it. It no-ops (false) once the game has filled and become active, so a caller can // fall back to resigning the now-active seat. Used to clear a blocked player from matchmaking. func (s *Store) DeleteOpenGame(ctx context.Context, gameID uuid.UUID) (bool, error) { stmt := table.Games.DELETE().WHERE( table.Games.GameID.EQ(postgres.UUID(gameID)). AND(table.Games.Status.EQ(postgres.String(StatusOpen))), ) res, err := stmt.ExecContext(ctx, s.db) if err != nil { return false, fmt.Errorf("game: delete open game %s: %w", gameID, err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("game: delete open game rows %s: %w", gameID, err) } return n > 0, nil } // ListGamesForAccount loads every game the account is seated in (active and // finished), newest first, each joined with its ordered seats. It backs the lobby's // "my games" lists. func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) { gstmt := postgres.SELECT(table.Games.AllColumns). FROM(table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID))). WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))). ORDER_BY(table.Games.UpdatedAt.DESC()) var grows []model.Games if err := gstmt.QueryContext(ctx, s.db, &grows); err != nil { return nil, fmt.Errorf("game: list for account: %w", err) } if len(grows) == 0 { return nil, nil } // Drop games the account has hidden from its own lobby. hidden, err := s.hiddenGameIDs(ctx, accountID) if err != nil { return nil, err } if len(hidden) > 0 { kept := grows[:0] for _, g := range grows { if !hidden[g.GameID] { kept = append(kept, g) } } grows = kept if len(grows) == 0 { return nil, nil } } ids := make([]postgres.Expression, len(grows)) for i, g := range grows { ids[i] = postgres.UUID(g.GameID) } sstmt := postgres.SELECT(table.GamePlayers.AllColumns). FROM(table.GamePlayers). WHERE(table.GamePlayers.GameID.IN(ids...)). ORDER_BY(table.GamePlayers.GameID.ASC(), table.GamePlayers.Seat.ASC()) var srows []model.GamePlayers if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil { return nil, fmt.Errorf("game: list seats for account: %w", err) } byGame := make(map[uuid.UUID][]model.GamePlayers, len(grows)) for _, r := range srows { byGame[r.GameID] = append(byGame[r.GameID], r) } out := make([]Game, 0, len(grows)) for _, g := range grows { pg, err := projectGame(g, byGame[g.GameID]) if err != nil { return nil, err } out = append(out, pg) } return out, nil } // CountActiveQuickGames counts the account's in-progress quick games — the ones the // simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both // active and still-open (awaiting-opponent) games, the honest-AI ones among them, and // excludes friend games (those linked to a game_invitations row) and finished games. // A hidden game still occupies a slot, so this is a dedicated count rather than a // filter over ListGamesForAccount (which drops hidden games). Joining on the account's // own seat counts each game once (an open game's empty opponent seat has no account). func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) { // The status literals are game.StatusActive / game.StatusOpen, matching the // games.status CHECK in the baseline migration. const q = ` SELECT COUNT(*) FROM backend.games g JOIN backend.game_players gp ON gp.game_id = g.game_id LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL` var n int if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil { return 0, fmt.Errorf("game: count active quick games: %w", err) } return n, nil } // HideGame hides a game from the account's own lobby list (idempotent). The caller validates the // game is finished and the account is a player. func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, accountID, gameID) if err != nil { return fmt.Errorf("game: hide game: %w", err) } return nil } // hiddenGameIDs returns the set of games the account has hidden from its lobby. func (s *Store) hiddenGameIDs(ctx context.Context, accountID uuid.UUID) (map[uuid.UUID]bool, error) { rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM backend.game_hidden WHERE account_id = $1`, accountID) if err != nil { return nil, fmt.Errorf("game: hidden ids: %w", err) } defer rows.Close() out := map[uuid.UUID]bool{} for rows.Next() { var id uuid.UUID if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("game: scan hidden id: %w", err) } out[id] = true } return out, rows.Err() } // ListGames returns games for the admin games list, most-recently-updated first, // paginated. status filters by lifecycle ("active"/"finished") when non-empty. // The seats are not loaded — the list shows summaries; the detail view uses // GetGame. func (s *Store) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) { where := postgres.Bool(true) if status != "" { where = table.Games.Status.EQ(postgres.String(status)) } stmt := postgres.SELECT(table.Games.AllColumns). FROM(table.Games). WHERE(where). ORDER_BY(table.Games.UpdatedAt.DESC()). LIMIT(int64(limit)). OFFSET(int64(offset)) var rows []model.Games if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("game: list games: %w", err) } out := make([]Game, 0, len(rows)) for _, g := range rows { pg, err := projectGame(g, nil) if err != nil { return nil, err } out = append(out, pg) } return out, nil } // CountGames returns the number of games, optionally restricted to a status, for // admin-list pagination. func (s *Store) CountGames(ctx context.Context, status string) (int, error) { where := postgres.Bool(true) if status != "" { where = table.Games.Status.EQ(postgres.String(status)) } stmt := postgres.SELECT(postgres.COUNT(table.Games.GameID).AS("count")). FROM(table.Games). WHERE(where) var dest struct{ Count int64 } if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { return 0, fmt.Errorf("game: count games: %w", err) } return int(dest.Count), nil } // 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 } if err := upsertBestMove(ctx, tx, d, c.now); err != nil { return err } } } return nil }) } // VoidGame closes a game that can no longer be reconstructed from its journal: it stamps the // finish (status 'finished', the end reason, finished_at), writes each seat's partial score // as a draw (is_winner false for all) and upserts the draw statistics, in one transaction. // Unlike CommitMove it appends no journal row and leaves the move cursor untouched. func (s *Store) VoidGame(ctx context.Context, v voidCommit) error { return withTx(ctx, s.db, func(tx *sql.Tx) error { gu := table.Games.UPDATE( table.Games.Status, table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt, ).SET( postgres.String(StatusFinished), postgres.String(v.endReason), postgres.TimestampzT(v.now), postgres.TimestampzT(v.now), ).WHERE(table.Games.GameID.EQ(postgres.UUID(v.gameID))) if _, err := gu.ExecContext(ctx, tx); err != nil { return fmt.Errorf("void game: %w", err) } for seat, score := range v.scores { if err := updateSeatScore(ctx, tx, v.gameID, seat, score, true, false); err != nil { return fmt.Errorf("void seat %d: %w", seat, err) } } for _, d := range v.stats { if err := upsertStats(ctx, tx, d, v.now); err != nil { return err } if err := upsertBestMove(ctx, tx, d, v.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)) moves := row.Moves + int32(d.moves) hintsUsed := row.HintsUsed + int32(d.hintsUsed) upd := table.AccountStats.UPDATE( table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws, table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt, table.AccountStats.Moves, table.AccountStats.HintsUsed, ).SET( postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)), postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now), postgres.Int(int64(moves)), postgres.Int(int64(hintsUsed)), ).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 } // upsertBestMove records the account's best play for a variant, keeping only the // highest-scoring one: a first play inserts, a later one replaces it only when it scored // strictly higher (the conditional DO UPDATE makes the upsert atomic under concurrent // finishes without a separate lock). It is a no-op when the finish carries no scoring play // for the account (a draw with no plays, or an exchange/pass-only game). func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error { if d.bestVariant == "" || len(d.bestTiles) == 0 { return nil } tiles, err := json.Marshal(d.bestTiles) if err != nil { return fmt.Errorf("marshal best move %s/%s: %w", d.accountID, d.bestVariant, err) } stmt := table.AccountBestMove. INSERT( table.AccountBestMove.AccountID, table.AccountBestMove.Variant, table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt, ). VALUES(d.accountID, d.bestVariant, d.bestScore, string(tiles), postgres.TimestampzT(now)). ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant). DO_UPDATE(postgres.SET( table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score), table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles), table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt), ).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score))) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("upsert best move %s/%s: %w", d.accountID, d.bestVariant, err) } return nil } // IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for // every hint — both the free per-game allowance and the wallet-charged ones — so the counter // is the seat's total hints used this game (the first HintsPerPlayer being the allowance). func (s *Store) IncHintsUsed(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: increment hints used: %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) } // ListComplaints returns complaints for the admin review queue, newest first. // status filters by lifecycle state when non-empty; limit and offset paginate. func (s *Store) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) { where := postgres.Bool(true) if status != "" { where = table.Complaints.Status.EQ(postgres.String(status)) } stmt := postgres.SELECT(table.Complaints.AllColumns). FROM(table.Complaints). WHERE(where). ORDER_BY(table.Complaints.CreatedAt.DESC()). LIMIT(int64(limit)). OFFSET(int64(offset)) var rows []model.Complaints if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("game: list complaints: %w", err) } return projectComplaints(rows) } // GetComplaint loads one complaint by id, or ErrNotFound. func (s *Store) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) { stmt := postgres.SELECT(table.Complaints.AllColumns). FROM(table.Complaints). WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))). LIMIT(1) var row model.Complaints if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Complaint{}, ErrNotFound } return Complaint{}, fmt.Errorf("game: get complaint %s: %w", id, err) } return projectComplaint(row) } // ResolveComplaint closes a complaint with a disposition and note, stamping // resolved_at, and returns the updated row (ErrNotFound when none matches). It // leaves applied_in_version untouched. func (s *Store) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string, now time.Time) (Complaint, error) { stmt := table.Complaints.UPDATE( table.Complaints.Status, table.Complaints.Disposition, table.Complaints.ResolutionNote, table.Complaints.ResolvedAt, ).SET( postgres.String(StatusComplaintResolved), postgres.String(disposition), postgres.String(note), postgres.TimestampzT(now), ).WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))). RETURNING(table.Complaints.AllColumns) var row model.Complaints if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Complaint{}, ErrNotFound } return Complaint{}, fmt.Errorf("game: resolve complaint %s: %w", id, err) } return projectComplaint(row) } // ListDictionaryChanges returns the resolved, accepted complaints not yet marked // applied (the pending wordlist edits), ordered by variant then resolution time. func (s *Store) ListDictionaryChanges(ctx context.Context) ([]Complaint, error) { stmt := postgres.SELECT(table.Complaints.AllColumns). FROM(table.Complaints). WHERE( table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)). AND(table.Complaints.Disposition.IN( postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove), )). AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))), ). ORDER_BY(table.Complaints.Variant.ASC(), table.Complaints.ResolvedAt.ASC()) var rows []model.Complaints if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("game: list dictionary changes: %w", err) } return projectComplaints(rows) } // MarkChangesApplied stamps every pending accepted change for variant with // version (so it drops out of ListDictionaryChanges) and returns the count. func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string) (int64, error) { stmt := table.Complaints.UPDATE(table.Complaints.AppliedInVersion). SET(postgres.String(version)). WHERE( table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)). AND(table.Complaints.Variant.EQ(postgres.String(variant))). AND(table.Complaints.Disposition.IN( postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove), )). AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))), ) res, err := stmt.ExecContext(ctx, s.db) if err != nil { return 0, fmt.Errorf("game: mark changes applied: %w", err) } n, _ := res.RowsAffected() return n, nil } // GetActiveDictVersion returns the persisted active dictionary version and true, // or ("", false, nil) when none has been recorded yet (a fresh database). It reads // the dictionary_state singleton (docs/ARCHITECTURE.md §5). func (s *Store) GetActiveDictVersion(ctx context.Context) (string, bool, error) { stmt := postgres.SELECT(table.DictionaryState.ActiveVersion). FROM(table.DictionaryState). WHERE(table.DictionaryState.ID.EQ(postgres.Bool(true))) var row model.DictionaryState if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return "", false, nil } return "", false, fmt.Errorf("game: get active dict version: %w", err) } return row.ActiveVersion, true, nil } // SetActiveDictVersion records version as the active dictionary version, upserting // the dictionary_state singleton so the choice survives a restart. func (s *Store) SetActiveDictVersion(ctx context.Context, version string) error { now := time.Now().UTC() stmt := table.DictionaryState. INSERT(table.DictionaryState.ID, table.DictionaryState.ActiveVersion, table.DictionaryState.UpdatedAt). VALUES(true, version, postgres.TimestampzT(now)). ON_CONFLICT(table.DictionaryState.ID). DO_UPDATE(postgres.SET( table.DictionaryState.ActiveVersion.SET(postgres.String(version)), table.DictionaryState.UpdatedAt.SET(postgres.TimestampzT(now)), )) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("game: set active dict version: %w", err) } return nil } // CountComplaints returns the number of complaints, optionally restricted to a // status, for the admin queue pager and the dashboard counts. func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) { where := postgres.Bool(true) if status != "" { where = table.Complaints.Status.EQ(postgres.String(status)) } stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")). FROM(table.Complaints). WHERE(where) var dest struct{ Count int64 } if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { return 0, fmt.Errorf("game: count complaints: %w", err) } return int(dest.Count), nil } // 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 } // RobotTurns returns one row per active game seating any of the given accounts, // for the robot scheduler. It joins games to game_players on the robot's seat and // carries the game's turn cursor and bag seed; the driver filters these against // each robot's per-game deadline. An empty id list returns no rows. func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, error) { if len(ids) == 0 { return nil, nil } exprs := make([]postgres.Expression, len(ids)) for i, id := range ids { exprs[i] = postgres.UUID(id) } stmt := postgres.SELECT( table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, table.Games.MoveCount, table.Games.Seed, table.Games.VsAi, table.GamePlayers.Seat, table.GamePlayers.AccountID, ).FROM( table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)), ).WHERE( table.Games.Status.EQ(postgres.String(StatusActive)). AND(table.GamePlayers.AccountID.IN(exprs...)), ).ORDER_BY(table.Games.TurnStartedAt.ASC()) var rows []struct { model.Games model.GamePlayers } if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("game: list robot turns: %w", err) } out := make([]RobotTurn, 0, len(rows)) for _, r := range rows { out = append(out, robotTurnFrom(r.Games, r.GamePlayers)) } return out, nil } // RobotTurnByGame returns the robot turn for a single active game — the seat held by // one of ids (the robot pool) — and true, or false when the game is not active, holds // no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which // drives one game at once rather than scanning the whole pool (RobotTurns). func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) { if len(ids) == 0 { return RobotTurn{}, false, nil } exprs := make([]postgres.Expression, len(ids)) for i, id := range ids { exprs[i] = postgres.UUID(id) } stmt := postgres.SELECT( table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, table.Games.MoveCount, table.Games.Seed, table.Games.VsAi, table.GamePlayers.Seat, table.GamePlayers.AccountID, ).FROM( table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)), ).WHERE( table.Games.GameID.EQ(postgres.UUID(gameID)). AND(table.Games.Status.EQ(postgres.String(StatusActive))). AND(table.GamePlayers.AccountID.IN(exprs...)), ).LIMIT(1) var rows []struct { model.Games model.GamePlayers } if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return RobotTurn{}, false, fmt.Errorf("game: robot turn by game: %w", err) } if len(rows) == 0 { return RobotTurn{}, false, nil } return robotTurnFrom(rows[0].Games, rows[0].GamePlayers), true, nil } // robotTurnFrom projects a games row joined with the robot's seat into a RobotTurn. // The query matches only the robot's (non-null) seat, so AccountID is set. func robotTurnFrom(g model.Games, p model.GamePlayers) RobotTurn { robotID := uuid.Nil if p.AccountID != nil { robotID = *p.AccountID } return RobotTurn{ GameID: g.GameID, RobotID: robotID, RobotSeat: int(p.Seat), ToMove: int(g.ToMove), TurnStartedAt: g.TurnStartedAt, MoveCount: int(g.MoveCount), Seed: g.Seed, VsAI: g.VsAi, } } // GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap // single-column read for the social chat/nudge gate, which must reject both in an // AI game even though it reports status 'active'. ErrNotFound when the game is gone. func (s *Store) GameVsAI(ctx context.Context, id uuid.UUID) (bool, error) { stmt := postgres.SELECT(table.Games.VsAi). 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 false, ErrNotFound } return false, fmt.Errorf("game: get vs_ai %s: %w", id, err) } return row.VsAi, 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 } // LastMoveAt returns the time of the account's most recent move in the game and true, or // the zero time and false when it has not moved. The social service uses it to reset the // nudge cooldown once the player has taken a turn. func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { var at sql.NullTime err := s.db.QueryRowContext(ctx, `SELECT MAX(m.created_at) FROM backend.game_moves m JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat WHERE m.game_id = $1 AND p.account_id = $2`, gameID, accountID).Scan(&at) if err != nil { return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err) } if !at.Valid { return time.Time{}, false, nil } return at.Time, true, nil } // TurnStartedAt returns the start time of a game's current turn (games.turn_started_at). // The social service uses it as the per-turn boundary for the one-chat-message-per-turn // limit; it advances only on a move, never on chat or a nudge. func (s *Store) TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error) { var at time.Time err := s.db.QueryRowContext(ctx, `SELECT turn_started_at FROM backend.games WHERE game_id = $1`, gameID).Scan(&at) if err != nil { return time.Time{}, fmt.Errorf("game: turn started at %s: %w", gameID, err) } return at, nil } // RobotSchedule returns a game's bag seed and current turn-start time. The admin console // combines them with the robot strategy to show a robot seat's play-to-win intent and its // next-move ETA. Both are server-only state, never part of the public game view. func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt). 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, time.Time{}, ErrNotFound } return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err) } return row.Seed, row.TurnStartedAt, 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) } dropout, err := engine.ParseDropoutTiles(g.DropoutTiles) 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), DropoutTiles: dropout, MoveCount: int(g.MoveCount), CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } out.MultipleWordsPerTurn = g.MultipleWordsPerTurn out.VsAI = g.VsAi 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 { // A NULL account_id is the still-empty opponent seat of an open game; surface it // as uuid.Nil so callers keep the "seats == players" invariant. accountID := uuid.Nil if p.AccountID != nil { accountID = *p.AccountID } out.Seats = append(out.Seats, Seat{ Seat: int(p.Seat), AccountID: accountID, Score: int(p.Score), HintsUsed: int(p.HintsUsed), IsWinner: p.IsWinner, DisplayName: p.DisplayName, }) } 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, Disposition: row.Disposition, ResolutionNote: row.ResolutionNote, ResolvedAt: row.ResolvedAt, AppliedInVersion: row.AppliedInVersion, }, nil } // projectComplaints projects a slice of complaint rows, preserving order. func projectComplaints(rows []model.Complaints) ([]Complaint, error) { out := make([]Complaint, 0, len(rows)) for _, r := range rows { c, err := projectComplaint(r) if err != nil { return nil, err } out = append(out, c) } return out, 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 }