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
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:
+196
-15
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user