feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s

Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place.

Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent".

Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
This commit is contained in:
Ilia Denisov
2026-06-12 16:00:22 +02:00
parent 10dc1f0d48
commit c305363ccd
42 changed files with 1248 additions and 768 deletions
+9 -1
View File
@@ -1,6 +1,8 @@
package game
import (
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
)
@@ -21,9 +23,15 @@ func gameSummary(g Game, names []string) notify.GameSummary {
if s.Seat >= 0 && s.Seat < len(names) {
name = names[s.Seat]
}
// An open game's still-empty opponent seat carries no account: send an empty id
// (not the nil-UUID string) so the client renders it as "searching for opponent".
accountID := ""
if s.AccountID != uuid.Nil {
accountID = s.AccountID.String()
}
seats = append(seats, notify.SeatStanding{
Seat: s.Seat,
AccountID: s.AccountID.String(),
AccountID: accountID,
DisplayName: name,
Score: s.Score,
HintsUsed: s.HintsUsed,
+108 -4
View File
@@ -145,6 +145,96 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
return svc.store.GetGame(ctx, id)
}
// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
// params and returns the game they land in immediately: another waiting player's open
// game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open
// game seating only the caller with an empty opponent seat that a human or the reaper's
// robot fills later. openDeadline is when the reaper substitutes a robot into a freshly
// opened game (ignored when joining one). The bag seed defaults to random; params.Seed
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
if _, err := svc.accounts.GetByID(ctx, accountID); err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID)
}
return Game{}, false, err
}
timeout := params.TurnTimeout
if timeout == 0 {
timeout = DefaultTurnTimeout
}
if !allowedTimeout(timeout) {
return Game{}, false, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
}
id, err := uuid.NewV7()
if err != nil {
return Game{}, false, fmt.Errorf("game: new id: %w", err)
}
seed := params.Seed
if seed == 0 {
seed = svc.rng()
}
deadline := openDeadline
ins := gameInsert{
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
seed: seed,
players: 2,
turnTimeoutSecs: int(timeout / time.Second),
hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
status: StatusOpen,
openDeadline: &deadline,
}
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat
// is left empty (uuid.Nil) for the opponent.
seats := []uuid.UUID{accountID, uuid.Nil}
if seed&1 == 1 {
seats = []uuid.UUID{uuid.Nil, accountID}
}
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats)
if err != nil {
return Game{}, false, err
}
if created {
svc.metrics.recordStarted(ctx, params.Variant)
}
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Game{}, false, err
}
return g, joined, nil
}
// AttachRobot seats robotID in the empty opponent seat of open game gameID and flips
// it to active, returning the now-active game and whether it attached (false, with a
// zero Game, when a human joined first). It backs the matchmaking reaper.
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (Game, bool, error) {
attached, err := svc.store.AttachRobot(ctx, gameID, robotID)
if err != nil {
return Game{}, false, err
}
if !attached {
return Game{}, false, nil
}
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Game{}, false, err
}
return g, true, nil
}
// ExpiredOpen returns the open games due for a robot substitution (deadline at or
// before now) for the matchmaking reaper.
func (svc *Service) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
return svc.store.ExpiredOpen(ctx, now)
}
// engineOp applies one transition to the live game, returning the decoded record
// and, for an exchange, the swapped tiles.
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
@@ -189,6 +279,11 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
if !ok {
return MoveResult{}, ErrNotAPlayer
}
// Resign needs a present opponent to award the win, so it is refused while the game
// is still waiting for one; the UI keeps the button disabled until then.
if pre.Status == StatusOpen {
return MoveResult{}, ErrNoOpponentYet
}
if pre.Status != StatusActive {
return MoveResult{}, ErrFinished
}
@@ -260,7 +355,10 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if !ok {
return MoveResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
// A move is allowed while the game is active or still open (the starter may move on
// their turn before an opponent joins); only a finished game rejects it. The turn
// check below keeps the starter off the still-empty opponent seat.
if pre.Status == StatusFinished {
return MoveResult{}, ErrFinished
}
if pre.ToMove != seat {
@@ -382,6 +480,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
summary := gameSummary(post, names)
intents := make([]notify.Intent, 0, 2*len(post.Seats))
for _, s := range post.Seats {
if s.AccountID == uuid.Nil {
continue // an open game's opponent seat is not yet filled — nobody to notify
}
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
}
// Game pushes are routed out-of-app by the game's own language, not the recipient's
@@ -406,6 +507,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
// seat, each with their own perspective + recipient-first score, so an offline player gets
// an out-of-app "game over" push (online players take it from the in-app refresh).
for _, s := range post.Seats {
if s.AccountID == uuid.Nil {
continue
}
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
over.Language = lang
intents = append(intents, over)
@@ -540,7 +644,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
if _, ok := pre.seatOf(accountID); !ok {
return EvalResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
if pre.Status == StatusFinished {
return EvalResult{}, ErrFinished
}
@@ -674,7 +778,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
if !ok {
return HintResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
if pre.Status == StatusFinished {
return HintResult{}, ErrFinished
}
if pre.ToMove != seat {
@@ -736,7 +840,7 @@ func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID)
if !ok {
return nil, ErrNotAPlayer
}
if pre.Status != StatusActive {
if pre.Status == StatusFinished {
return nil, ErrFinished
}
if pre.ToMove != seat {
+196 -15
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"hash/fnv"
"time"
"github.com/go-jet/jet/v2/postgres"
@@ -40,6 +41,13 @@ type gameInsert struct {
dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn 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.
@@ -91,24 +99,186 @@ type activeGame struct {
// first) inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
gi := table.Games.INSERT(
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn)
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
return insertGameTx(ctx, tx, ins, seats)
})
}
// insertGameTx inserts the games row and one game_players row per seat (seat 0
// first) on tx. A seat whose account id is uuid.Nil is written with a NULL
// account_id — the still-empty opponent seat of a StatusOpen auto-match game.
func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.UUID) 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,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn)
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
for seat, accountID := range seats {
var acc any = accountID
if accountID == uuid.Nil {
acc = postgres.NULL
}
for seat, accountID := range seats {
pi := table.GamePlayers.INSERT(
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
).VALUES(ins.id, seat, accountID)
if _, err := pi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert seat %d: %w", seat, err)
pi := table.GamePlayers.INSERT(
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
).VALUES(ins.id, seat, acc)
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 re-uses the caller's own still-open game (joined=false, created=false,
// a re-enqueue is idempotent), 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). 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.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameInsert, seats []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. The caller's own still-open game for this bucket — a re-enqueue is idempotent.
var own uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3
LIMIT 1`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); {
case e == nil:
gameID = own
return nil
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find own open game: %w", e)
}
// 2. Another player's open game waiting for an opponent — fill its seat and start it.
var other uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
WHERE p.game_id = g.game_id AND p.account_id = $3)
ORDER BY g.created_at
LIMIT 1 FOR UPDATE SKIP LOCKED`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
case e == nil:
if er := fillOpenSeat(ctx, tx, other, accountID); er != nil {
return er
}
gameID, joined = other, true
return nil
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find open game: %w", e)
}
// 3. 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 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) (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); e != nil {
return e
}
attached = true
return nil
})
return attached, err
}
// fillOpenSeat seats accountID in an open game's empty opponent seat and flips the
// game to active, stamping a fresh turn clock. The caller holds the game row.
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID) error {
if _, err := tx.ExecContext(ctx,
`UPDATE backend.game_players SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
gameID, accountID); 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
@@ -670,9 +840,14 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
}
out := make([]RobotTurn, 0, len(rows))
for _, r := range rows {
// The filter matches only the robot's (non-null) seat, so AccountID is set.
robotID := uuid.Nil
if r.GamePlayers.AccountID != nil {
robotID = *r.GamePlayers.AccountID
}
out = append(out, RobotTurn{
GameID: r.Games.GameID,
RobotID: r.GamePlayers.AccountID,
RobotID: robotID,
RobotSeat: int(r.GamePlayers.Seat),
ToMove: int(r.Games.ToMove),
TurnStartedAt: r.Games.TurnStartedAt,
@@ -773,9 +948,15 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
}
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: p.AccountID,
AccountID: accountID,
Score: int(p.Score),
HintsUsed: int(p.HintsUsed),
IsWinner: p.IsWinner,
+18 -1
View File
@@ -13,11 +13,16 @@ import (
const (
StatusActive = "active"
StatusFinished = "finished"
// StatusOpen is an auto-match game whose starter has already entered it but
// which is still waiting for an opponent: the opponent seat holds no account
// yet. The starter may move on their turn; it becomes StatusActive when a human
// or a robot joins (see OpenGame, Service.OpenOrJoin and Service.AttachRobot).
StatusOpen = "open"
)
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen
// and closed StatusComplaintResolved by the admin review queue with a
// Disposition. The CHECK constraints live in migration 00008.
// Disposition. The CHECK constraints live in the baseline migration.
const (
StatusComplaintOpen = "open"
StatusComplaintResolved = "resolved"
@@ -43,6 +48,10 @@ var (
ErrNotYourTurn = errors.New("game: not the player's turn")
// ErrFinished is returned when a transition is attempted on a finished game.
ErrFinished = errors.New("game: game is finished")
// ErrNoOpponentYet is returned when an action that needs a present opponent
// (resign, chat, nudge) is attempted on an auto-match game still waiting for one
// (StatusOpen).
ErrNoOpponentYet = errors.New("game: no opponent has joined yet")
// ErrGameActive is returned when an operation allowed only on a finished game
// (such as a GCG export) is attempted while the game is still active.
ErrGameActive = errors.New("game: game is still active")
@@ -117,6 +126,14 @@ type Seat struct {
IsWinner bool
}
// OpenGame identifies an auto-match game waiting for an opponent whose robot
// deadline has passed: the game the matchmaking reaper fills and the variant that
// selects the substitute robot.
type OpenGame struct {
ID uuid.UUID
Variant engine.Variant
}
// seatOf returns the seat index of accountID and true, or (0, false) when the
// account is not seated.
func (g Game) seatOf(accountID uuid.UUID) (int, bool) {