fix(matchmaking): re-enqueue opens a new game, not the caller's own
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
A second "random opponent" enqueue with the same variant and per-turn rule, while the caller's first game was still open (awaiting an opponent), returned that same open game, so a player could never start a fresh random game while one was still searching. Drop the own-open short-circuit (step 1) in store.OpenOrJoin: a re-enqueue now joins another player's open game or opens a fresh one. Accumulation stays bounded by MaxActiveQuickGames, which counts open games. Update the matchmaker/service/store doc comments and ARCHITECTURE.md, and flip the pinning test to assert the new behavior.
This commit is contained in:
@@ -266,10 +266,11 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
|
||||
// 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
|
||||
// game (joined=true), or a fresh open game seating only the caller with an empty
|
||||
// opponent seat that a human or the reaper's robot fills later. A re-enqueue while the
|
||||
// caller is already waiting opens another game rather than returning their own.
|
||||
// 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.
|
||||
|
||||
@@ -188,37 +188,25 @@ func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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.
|
||||
// lands in: it joins another player's waiting open game and flips it active
|
||||
// (joined=true), or opens a fresh game seating the caller with an empty opponent seat
|
||||
// (created=true). A re-enqueue while the caller already has an open game in the bucket
|
||||
// opens another fresh game (or joins a different player's) rather than returning the
|
||||
// caller's own, so tapping "random opponent" again always starts a new search. ins
|
||||
// supplies the new game's immutable fields and is used only when a game is created. A
|
||||
// transaction-scoped advisory lock on the (variant, rule) bucket serialises concurrent
|
||||
// enqueues so two callers pair rather than each opening a game. seats is the two-seat
|
||||
// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen
|
||||
// order) used only when a game is created; callerName is the caller's display-name
|
||||
// snapshot, stamped on their seat whether they open a fresh game or fill another
|
||||
// player's open one.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
return fmt.Errorf("open match lock: %w", e)
|
||||
}
|
||||
// 1. 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.
|
||||
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
|
||||
var other uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT g.game_id FROM backend.games g
|
||||
@@ -237,7 +225,7 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
|
||||
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).
|
||||
// 2. None waiting — open a fresh game seating the caller (the other seat empty).
|
||||
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user