Files
scrabble-game/backend/internal/game/store.go
T
Ilia Denisov f3768d20f2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
feat(robot): shrink endgame think time when both sides pass
In a dead-drawn endgame — the two most recent journal moves are both
passes, so the board and the robot's rack are frozen and the robot is
bound to pass again — the robot still waited out its long late-game think
time (up to 90 min) before passing, needlessly dragging out a decided game.

Shorten that delay to a [0.8, 1.5]x band around the human's last-move think
time (the gap between the last two journal entries), clamped to [30s, 8min]
and taken as a min with the normal schedule, so the robot never moves
slower. A slow human collapses to the 8-min cap; a fast human is tracked,
with the floor keeping the robot from passing suspiciously instantly. The
anchor reads the move journal only (no schema change), stays deterministic
from the seed, and still defers to the sleep window.

RobotTurns now carries EndgamePass + OppLastMove, filled by one batched
journal query on the scan; the honest-AI single-game trigger keeps the
normal path (it moves at once). NextMoveAt (admin ETA) is left as the
normal-schedule upper bound.
2026-06-19 12:48:39 +02:00

1352 lines
50 KiB
Go

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, exclude []uuid.UUID) (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.
// A game whose waiting player is in exclude (the caller's per-user block set, either
// direction) is skipped, so a block keeps the pair out of the same anonymous game;
// an empty exclude (the "{}" literal) excludes nothing.
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)
AND NOT EXISTS (SELECT 1 FROM backend.game_players b
WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[]))
ORDER BY g.created_at
LIMIT 1 FOR UPDATE SKIP LOCKED`,
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).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))
}
if err := s.fillEndgamePass(ctx, out); err != nil {
return nil, err
}
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). It leaves
// EndgamePass false: honest-AI games move at once, so the endgame think-time shrink is a
// human-mimicry concern computed only on the RobotTurns scan.
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,
}
}
// fillEndgamePass marks the turns whose game is a dead-drawn endgame — its two most
// recent committed moves are both passes, so the board and racks are frozen and the
// seated robot is bound to pass again — setting EndgamePass and OppLastMove from the
// move journal so the driver can shorten the robot's think time. Turns whose game is
// not in that state are left unchanged. A nil or empty slice is a no-op. It runs one
// batched journal query for the whole scan, so it adds no per-game round trip.
func (s *Store) fillEndgamePass(ctx context.Context, turns []RobotTurn) error {
if len(turns) == 0 {
return nil
}
ids := make([]uuid.UUID, len(turns))
for i := range turns {
ids[i] = turns[i].GameID
}
info, err := s.endgamePassInfo(ctx, ids)
if err != nil {
return err
}
for i := range turns {
if d, ok := info[turns[i].GameID]; ok {
turns[i].EndgamePass = true
turns[i].OppLastMove = d
}
}
return nil
}
// endgamePassInfo returns, for each of ids whose two most recent committed moves are
// both passes, the human's think time on the most recent of them (the gap between the
// last two journal entries' created_at). Games with fewer than two moves, or whose last
// two are not both passes, are absent from the map. It reads the move journal only — no
// schema change — mirroring the analytics.go duration reports. A negative gap (clock
// skew) is floored to zero.
func (s *Store) endgamePassInfo(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]time.Duration, error) {
if len(ids) == 0 {
return map[uuid.UUID]time.Duration{}, nil
}
const q = `
SELECT q.game_id, q.secs FROM (
SELECT t.game_id,
bool_and(t.action = 'pass') AS both_pass,
COUNT(*) AS n,
EXTRACT(EPOCH FROM (MAX(t.created_at) - MIN(t.created_at))) AS secs
FROM (
SELECT m.game_id, m.action, m.created_at,
ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq DESC) AS rn
FROM backend.game_moves m
WHERE m.game_id = ANY($1::uuid[])
) t
WHERE t.rn <= 2
GROUP BY t.game_id
) q
WHERE q.n = 2 AND q.both_pass`
rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(ids))
if err != nil {
return nil, fmt.Errorf("game: endgame pass info: %w", err)
}
defer rows.Close()
out := make(map[uuid.UUID]time.Duration, len(ids))
for rows.Next() {
var id uuid.UUID
var secs float64
if err := rows.Scan(&id, &secs); err != nil {
return nil, fmt.Errorf("game: scan endgame pass info: %w", err)
}
if secs < 0 {
secs = 0
}
out[id] = time.Duration(secs * float64(time.Second))
}
return out, rows.Err()
}
// 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
}