caefc8f579
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Decide who moves first by the official rule: each seated player draws one tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader remains. Each draw uses honest per-draw crypto/rand entropy (not the deterministic bag seed), so the recorded draw — not a seed — is the only account of the outcome. The leader takes seat 0, so the engine and journal replay are unchanged. The draw is recorded with the game (game_setup_draws, migration 00013) for future tournaments, designed as a discrete "player N draws a tile" step. Friend/AI games draw at creation. Auto-match draws when the game opens, against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are back-filled to the real opponent on join — so the opener's seat is fixed up front and the existing open-game pre-move is preserved with no reseating. Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with A-O/1-15 headers and highlighted premium squares, placed letters with their tile value as a subscript, rack panels around the board (seat 0 top, 1 bottom, 2 left, 3 right) with the current player highlighted, and a per-move log with the tiles drawn and the bag remainder. Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
1424 lines
54 KiB
Go
1424 lines
54 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, one game_players row per seat (seat 0 first) and
|
|
// the first-move seeding draws inside a single transaction.
|
|
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert, draws []SetupDraw) error {
|
|
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
if err := insertGameTx(ctx, tx, ins, seats); err != nil {
|
|
return err
|
|
}
|
|
return insertSetupDrawsTx(ctx, tx, ins.id, draws)
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// insertSetupDrawsTx appends the first-move seeding draws for game gameID on tx —
|
|
// the dictionary-independent record of how the first player was chosen
|
|
// (docs/ARCHITECTURE.md §6). The games row must already exist on tx (the foreign
|
|
// key), so it runs after insertGameTx. An empty draws slice is a no-op.
|
|
func insertSetupDrawsTx(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, draws []SetupDraw) error {
|
|
for _, d := range draws {
|
|
// A uuid.Nil account marks the synthetic opponent of an auto-match draw, persisted as
|
|
// a NULL account_id and back-filled when a real opponent joins.
|
|
var acc any = d.Account
|
|
if d.Account == uuid.Nil {
|
|
acc = postgres.NULL
|
|
}
|
|
di := table.GameSetupDraws.INSERT(
|
|
table.GameSetupDraws.GameID, table.GameSetupDraws.Round, table.GameSetupDraws.PickNo,
|
|
table.GameSetupDraws.AccountID, table.GameSetupDraws.Letter, table.GameSetupDraws.IsBlank,
|
|
table.GameSetupDraws.DrawRank,
|
|
).VALUES(gameID, d.Round, d.PickNo, acc, d.Letter, d.Blank, d.Rank)
|
|
if _, err := di.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("insert setup draw round %d pick %d: %w", d.Round, d.PickNo, 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. seats and draws (the first-move draw recorded against the synthetic
|
|
// opponent, docs/ARCHITECTURE.md §6) are used only when a game is created.
|
|
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID, draws []SetupDraw) (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 := fillAndActivate(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) and
|
|
// recording the first-move draw; the synthetic opponent's rows carry a NULL account
|
|
// until a real opponent joins and back-fills them.
|
|
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
|
|
return e
|
|
}
|
|
if e := insertSetupDrawsTx(ctx, tx, ins.id, draws); 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 := fillAndActivate(ctx, tx, gameID, robotID, displayName); e != nil {
|
|
return e
|
|
}
|
|
attached = true
|
|
return nil
|
|
})
|
|
return attached, err
|
|
}
|
|
|
|
// fillAndActivate seats accountID in an open game's empty opponent seat — stamping
|
|
// displayName as the seat's snapshot — back-fills the first-move draw rows the open game
|
|
// recorded for the then-unknown opponent (docs/ARCHITECTURE.md §6), and flips the game to
|
|
// active with a fresh turn clock. The seat the joiner takes was already fixed by the draw at
|
|
// open time, so the journal (any opening move the starter made while waiting) is never
|
|
// disturbed. The caller holds the game row.
|
|
func fillAndActivate(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.game_setup_draws SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
|
|
gameID, accountID); err != nil {
|
|
return fmt.Errorf("back-fill opponent draws: %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
|
|
}
|
|
|
|
// SetupDraws loads the ordered first-move seeding draws for a game (round then pick
|
|
// order), or an empty slice for a game created before the draw was recorded. It
|
|
// backs the admin console's first-move section.
|
|
func (s *Store) SetupDraws(ctx context.Context, id uuid.UUID) ([]SetupDraw, error) {
|
|
stmt := postgres.SELECT(table.GameSetupDraws.AllColumns).
|
|
FROM(table.GameSetupDraws).
|
|
WHERE(table.GameSetupDraws.GameID.EQ(postgres.UUID(id))).
|
|
ORDER_BY(table.GameSetupDraws.Round.ASC(), table.GameSetupDraws.PickNo.ASC())
|
|
var rows []model.GameSetupDraws
|
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
|
return nil, fmt.Errorf("game: get setup draws %s: %w", id, err)
|
|
}
|
|
out := make([]SetupDraw, len(rows))
|
|
for i, r := range rows {
|
|
// A NULL account is the synthetic opponent of an auto-match draw not yet back-filled.
|
|
acc := uuid.Nil
|
|
if r.AccountID != nil {
|
|
acc = *r.AccountID
|
|
}
|
|
out[i] = SetupDraw{
|
|
Round: int(r.Round),
|
|
PickNo: int(r.PickNo),
|
|
Account: acc,
|
|
Letter: r.Letter,
|
|
Blank: r.IsBlank,
|
|
Rank: int(r.DrawRank),
|
|
}
|
|
}
|
|
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
|
|
}
|