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
+11 -9
View File
@@ -28,9 +28,10 @@ export, per-account statistics on finish, and a background turn-timeout sweeper
that auto-resigns overdue turns (honouring each player's daily away window). Like
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
The lobby and social fabric. `internal/lobby` holds an in-memory
matchmaking pool (FIFO per variant and per-turn word rule, pairs two humans into an
auto-match) and
The lobby and social fabric. `internal/lobby` runs **auto-match**`Enqueue` opens a
real game seating the caller with an **empty opponent seat** (status `open`) or, when
another player already waits for the same variant and per-turn word rule, seats the
caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
@@ -52,16 +53,17 @@ robot's moves through the public game API as an ordinary seated player (so only
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
initial game state) drives it instead.
state. A background **reaper** seats a pooled robot (matching the game's language) in any open
game whose wait window — a fixed **90 s** plus a random **090 s** (so **90180 s**) — has
elapsed, and the waiting starter is told an opponent took the seat by an in-app
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
re-enables resign and chat in place.
The backend opens to the edge. The route groups gain their first
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
slice of authenticated `/api/v1/user` operations (profile, submit play, game
state, lobby enqueue/poll, chat). The social/account/history operations under
state, lobby enqueue, chat). The social/account/history operations under
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
@@ -126,7 +128,7 @@ internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
+4 -2
View File
@@ -170,12 +170,14 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
go robots.Run(ctx, cfg.Robot.DriveInterval)
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger)
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
matchmaker.SetNotifier(hub)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
invitations.SetNotifier(hub)
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
logger.Info("lobby and social domains ready",
zap.Duration("robot_wait", cfg.Lobby.RobotWait),
zap.Duration("robot_wait_jitter", cfg.Lobby.RobotWaitJitter))
// Rate-limit observability: ingest the gateway's rejection reports for the
// admin throttled view and the conservative high-rate auto-flag.
+3
View File
@@ -100,6 +100,9 @@ func Load() (Config, error) {
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
return Config{}, err
}
if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil {
return Config{}, err
}
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
return Config{}, err
}
+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) {
+14 -4
View File
@@ -56,11 +56,21 @@ func newRobotService(t *testing.T, games *game.Service) *robot.Service {
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
}
// newMatchmaker builds a matchmaker starting real games and substituting from
// robots after wait.
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
// newMatchmaker builds a matchmaker opening real games and substituting from robots
// after minWait plus a random jitter in [0, jitter).
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, minWait, jitter time.Duration) *lobby.Matchmaker {
t.Helper()
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
return lobby.NewMatchmaker(newGameService(), robots, minWait, jitter, zap.NewNop())
}
// clearOpenGames deletes every open (awaiting-opponent) game so a matchmaking test
// starts from a clean slate: the shared test database is FIFO-joined across tests, so a
// leftover open game would otherwise be joined (or opened-into) instead of a fresh one.
func clearOpenGames(t *testing.T) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(), `DELETE FROM backend.games WHERE status = 'open'`); err != nil {
t.Fatalf("clear open games: %v", err)
}
}
// provisionAccount creates a fresh durable account and returns its id.
+43 -7
View File
@@ -30,31 +30,67 @@ func englishInvite() lobby.InvitationSettings {
}
}
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
func TestMatchmakingOpensThenJoins(t *testing.T) {
ctx := context.Background()
mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second)
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
a, b := provisionAccount(t), provisionAccount(t)
// The first player opens a game and enters it immediately, still awaiting an opponent.
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must wait")
t.Fatal("first enqueue must open a game awaiting an opponent, not match")
}
if _, _, status, err := newGameService().Participants(ctx, r1.Game.ID); err != nil || status != "open" {
t.Fatalf("opened game status = %q err %v, want open", status, err)
}
// A second player for the same variant and rule joins that open game, which starts.
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if !r2.Matched {
t.Fatal("second enqueue must match")
if !r2.Matched || r2.Game.ID != r1.Game.ID {
t.Fatalf("second enqueue = (matched %v, game %s), want it to join the open game %s", r2.Matched, r2.Game.ID, r1.Game.ID)
}
seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != "active" || len(seats) != 2 {
t.Fatalf("matched game state: status %q seats %v", status, seats)
has := func(id uuid.UUID) bool {
for _, s := range seats {
if s == id {
return true
}
}
return false
}
if status != "active" || len(seats) != 2 || !has(a) || !has(b) {
t.Fatalf("joined game: status %q seats %v (want active with a=%s and b=%s)", status, seats, a, b)
}
}
// TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the
// caller gets their existing open game rather than a second one.
func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
a := provisionAccount(t)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("re-enqueue: %v", err)
}
if r2.Game.ID != r1.Game.ID || r2.Matched {
t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID)
}
}
+254
View File
@@ -0,0 +1,254 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social"
)
// The open-game suite covers an auto-match game that a player enters immediately and
// waits inside (status 'open', the opponent seat empty) until a human or a robot joins.
// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
// moves first) whose fresh two-player English opening rack has a legal move.
func evenOpeningSeed(t *testing.T) int64 {
t.Helper()
for seed := int64(2); seed <= 400; seed += 2 {
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
if err != nil {
t.Fatalf("engine new: %v", err)
}
if _, ok := g.HintView(); ok {
return seed
}
}
t.Fatal("no even opening seed found")
return 0
}
// openParams are the casual auto-match settings with a pinned seed.
func openParams(seed int64) game.CreateParams {
return game.CreateParams{
Variant: engine.VariantEnglish,
TurnTimeout: 24 * time.Hour,
HintsAllowed: true,
HintsPerPlayer: 1,
MultipleWordsPerTurn: true,
Seed: seed,
}
}
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
t.Helper()
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("open game: %v", err)
}
if joined || g.Status != game.StatusOpen {
t.Fatalf("opened game = (joined %v, status %q), want (false, open)", joined, g.Status)
}
return g
}
// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
// while the game is open, after which it is the empty opponent seat's turn and the
// starter just waits.
func TestOpenGameStarterMovesThenWaits(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
seed := evenOpeningSeed(t)
g := openGame(t, svc, provisionAccount(t), seed)
starter := g.Seats[0].AccountID
hint, ok := newMirror(t, seed, 2).HintView()
if !ok || len(hint.Tiles) == 0 {
t.Fatal("no opening move for the seed")
}
res, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles)
if err != nil {
t.Fatalf("starter play while open: %v", err)
}
if res.Game.Status != game.StatusOpen {
t.Errorf("after the starter's move the game must stay open, got %q", res.Game.Status)
}
if res.Game.ToMove != 1 {
t.Errorf("after the starter's move it must be the empty seat's turn (to_move 1), got %d", res.Game.ToMove)
}
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
t.Fatalf("starter acting on the opponent's turn = %v, want ErrNotYourTurn", err)
}
}
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("open: %v", err)
}
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
}
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err)
}
}
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after
// the starter has already made their first move, landing on the board mid-opening and
// able to act on their turn.
func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
seed := evenOpeningSeed(t)
g := openGame(t, svc, provisionAccount(t), seed)
starter := g.Seats[0].AccountID
hint, ok := newMirror(t, seed, 2).HintView()
if !ok {
t.Fatal("no opening move")
}
if _, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles); err != nil {
t.Fatalf("starter play: %v", err)
}
joiner := provisionAccount(t)
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("join: %v", err)
}
if !joined || g2.ID != g.ID {
t.Fatalf("join = (joined %v, game %s), want it to join %s", joined, g2.ID, g.ID)
}
if g2.Status != game.StatusActive || g2.MoveCount != 1 || g2.ToMove != 1 {
t.Fatalf("joined game = (status %q, moves %d, to_move %d), want (active, 1, 1)", g2.Status, g2.MoveCount, g2.ToMove)
}
// It is the joiner's turn (seat 1); they can act.
if _, err := svc.Pass(ctx, g.ID, joiner); err != nil {
t.Fatalf("joiner pass on their turn: %v", err)
}
}
// TestOpenGameResignRejectedUntilOpponent checks resign is refused while the game is
// open and allowed once an opponent (a robot here) has joined.
func TestOpenGameResignRejectedUntilOpponent(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
starter := g.Seats[0].AccountID
if _, err := svc.Resign(ctx, g.ID, starter); !errors.Is(err, game.ErrNoOpponentYet) {
t.Fatalf("resign while open = %v, want ErrNoOpponentYet", err)
}
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
if _, attached, err := svc.AttachRobot(ctx, g.ID, robotID); err != nil || !attached {
t.Fatalf("attach robot = (attached %v, err %v), want attached", attached, err)
}
res, err := svc.Resign(ctx, g.ID, starter)
if err != nil {
t.Fatalf("resign after the opponent joined: %v", err)
}
if res.Game.Status != game.StatusFinished {
t.Errorf("resign must finish the game, got %q", res.Game.Status)
}
}
// TestOpenGameChatAndNudgeRejected checks chat and nudge are refused while the game is
// open (no opponent to converse with).
func TestOpenGameChatAndNudgeRejected(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
soc := newSocialService()
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
starter := g.Seats[0].AccountID
if _, err := soc.PostMessage(ctx, g.ID, starter, "hello", ""); !errors.Is(err, social.ErrGameNotActive) {
t.Errorf("chat while open = %v, want ErrGameNotActive", err)
}
if _, err := soc.Nudge(ctx, g.ID, starter); !errors.Is(err, social.ErrGameNotActive) {
t.Errorf("nudge while open = %v, want ErrGameNotActive", err)
}
}
// TestOpenGameSweeperSkips checks the turn-timeout sweeper never finishes an open game,
// even with a long-stale turn clock (its seat is the empty opponent's).
func TestOpenGameSweeperSkips(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
setTurnStarted(t, g.ID, time.Now().Add(-1000*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
g2, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if g2.Status != game.StatusOpen {
t.Errorf("open game must survive the sweeper, got %q", g2.Status)
}
}
// TestOpenGameLobbyShowsEmptySeat checks an open game appears in the starter's lobby
// with two seats, one of them the still-empty opponent (uuid.Nil).
func TestOpenGameLobbyShowsEmptySeat(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g := openGame(t, svc, starter, evenOpeningSeed(t))
games, err := svc.ListForAccount(ctx, starter)
if err != nil {
t.Fatalf("list for account: %v", err)
}
var found *game.Game
for i := range games {
if games[i].ID == g.ID {
found = &games[i]
break
}
}
if found == nil {
t.Fatal("open game must appear in the starter's lobby")
}
var hasStarter, hasEmpty bool
for _, s := range found.Seats {
switch s.AccountID {
case starter:
hasStarter = true
case uuid.Nil:
hasEmpty = true
}
}
if len(found.Seats) != 2 || !hasStarter || !hasEmpty {
t.Errorf("open game seats = %+v, want the starter and one empty (nil) seat", found.Seats)
}
}
+8 -15
View File
@@ -138,36 +138,29 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
}
}
// TestMatchmakerSubstitutesRobotEndToEnd checks a waiting human is paired with a
// real robot account after the wait window, discoverable through Poll.
// TestMatchmakerSubstitutesRobotEndToEnd checks the reaper fills an open game's empty
// seat with a real robot account once its wait window has elapsed.
func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
robots := newRobotService(t, newGameService())
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
mm := newMatchmaker(t, robots, 10*time.Second)
// Zero wait and jitter so the opened game is immediately due for a robot.
mm := newMatchmaker(t, robots, 0, 0)
human := provisionAccount(t)
before := time.Now()
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
if r.Matched {
t.Fatal("first enqueue must wait")
t.Fatal("first enqueue must open a game awaiting an opponent")
}
mm.Reap(ctx, before.Add(11*time.Second))
got, err := mm.Poll(ctx, human)
if err != nil {
t.Fatalf("poll: %v", err)
}
if !got.Matched {
t.Fatal("expected a substituted game after the wait window")
}
seats, _, status, err := newGameService().Participants(ctx, got.Game.ID)
mm.Reap(ctx, time.Now().Add(time.Second)) // past the (zero) wait window
seats, _, status, err := newGameService().Participants(ctx, r.Game.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
+20 -9
View File
@@ -5,22 +5,30 @@ import (
"time"
)
// Config configures the matchmaking pool's robot substitution.
// Config configures auto-match robot substitution: how long an open game waits for a
// human opponent before a robot is substituted, and how often the reaper scans.
type Config struct {
// RobotWait is how long an auto-match player waits for a human before a robot
// is substituted. Sourced from BACKEND_LOBBY_ROBOT_WAIT.
// RobotWait is the fixed minimum an open auto-match game waits for a human
// opponent before it is eligible for robot substitution. Sourced from
// BACKEND_LOBBY_ROBOT_WAIT.
RobotWait time.Duration
// ReaperInterval is how often the substitution reaper scans for over-waited
// players. Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
// RobotWaitJitter is a random extra wait in [0, RobotWaitJitter) added on top of
// RobotWait per game, so the substitution time varies. Sourced from
// BACKEND_LOBBY_ROBOT_WAIT_JITTER.
RobotWaitJitter time.Duration
// ReaperInterval is how often the reaper scans for open games due for a robot.
// Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
ReaperInterval time.Duration
}
// DefaultConfig returns the matchmaking defaults: a 10-second wait
// (docs/ARCHITECTURE.md §7) scanned every second.
// DefaultConfig returns the matchmaking defaults: a guaranteed 90-second wait for a
// human plus up to 90 random seconds (90180 s total) before a robot substitutes
// (docs/ARCHITECTURE.md §7), scanned every five seconds.
func DefaultConfig() Config {
return Config{
RobotWait: 10 * time.Second,
ReaperInterval: time.Second,
RobotWait: 90 * time.Second,
RobotWaitJitter: 90 * time.Second,
ReaperInterval: 5 * time.Second,
}
}
@@ -29,6 +37,9 @@ func (c Config) Validate() error {
if c.RobotWait <= 0 {
return fmt.Errorf("lobby: robot wait must be positive, got %s", c.RobotWait)
}
if c.RobotWaitJitter < 0 {
return fmt.Errorf("lobby: robot wait jitter must not be negative, got %s", c.RobotWaitJitter)
}
if c.ReaperInterval <= 0 {
return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval)
}
+9 -10
View File
@@ -1,9 +1,10 @@
// Package lobby forms games: an in-memory matchmaking pool that pairs two humans
// for an auto-match, and friend-game invitations (invite -> accept) that start a
// 2-4 player game once every invitee has accepted. Both produce a game through the
// game domain (a GameCreator); neither imports the engine. The matchmaking pool
// is in-memory and lost on restart (players re-queue); the robot that substitutes
// for a missing human after a short wait is added in a later stage.
// Package lobby forms games: an auto-match maker that drops a player straight into a
// game with an empty opponent seat (or joins them into another player's waiting one),
// and friend-game invitations (invite -> accept) that start a 2-4 player game once
// every invitee has accepted. Both produce games through the game domain; neither
// imports the engine. Auto-match state is the open games in the database, so it
// survives a restart; a background reaper substitutes a pooled robot for any open game
// that waits too long, guaranteeing every game gets an opponent.
package lobby
import (
@@ -22,8 +23,8 @@ import (
type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
// InitialState returns a seated player's full initial view of a started game, used
// to enrich the match_found / game_started events so the client renders the new game
// without a follow-up fetch.
// to enrich the game_started event so the client renders the new game without a
// follow-up fetch.
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
}
@@ -51,8 +52,6 @@ const (
// Sentinel errors returned by the lobby.
var (
// ErrAlreadyQueued is returned when an account already waits in a pool.
ErrAlreadyQueued = errors.New("lobby: account already in the matchmaking pool")
// ErrInvalidInvitation is returned for a malformed invitation (bad player
// count, duplicate or self invitee, or unacceptable settings).
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
+113 -199
View File
@@ -2,8 +2,7 @@ package lobby
import (
"context"
"math/rand"
"sync"
"math/rand/v2"
"time"
"github.com/google/uuid"
@@ -14,182 +13,91 @@ import (
"scrabble/backend/internal/notify"
)
// matchKey buckets the auto-match pool: two players are paired only when they chose
// the same variant and the same per-turn word rule (multipleWords), so a game always
// starts under a rule both players asked for.
type matchKey struct {
variant engine.Variant
multipleWords bool
// GameMatcher is the slice of the game domain the matchmaker drives: opening or
// joining an auto-match game, substituting a robot into one whose wait elapsed, and
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
// it.
type GameMatcher interface {
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error)
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
}
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
// the next two humans into a two-player game, or — when no human arrives within
// the wait window — substitutes a robot. It holds no database state and is lost on
// restart (players simply re-queue). It is safe for concurrent use.
// Matchmaker turns an auto-match enqueue into a real game the player enters at once:
// it opens a game with an empty opponent seat, or joins the caller into another
// player's waiting one. A background reaper substitutes a pooled robot for any open
// game whose wait window has elapsed, guaranteeing every game gets an opponent. All
// matchmaking state is the open games in the database, so it survives a restart; the
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
// concurrent use.
//
// Auto-match is anonymous, so the pool does not consult per-user blocks (those
// govern friends, chat and invitations between known players).
//
// A player who is queued learns of a match — by a waiting human being paired, or
// by robot substitution — through Poll, the interim delivery seam: production
// delivery is a notification (session/in-app push and the platform side-service,
// docs/ARCHITECTURE.md §10), wired with the gateway in a later stage.
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
// friends, chat and invitations between known players).
type Matchmaker struct {
games GameCreator
robots RobotProvider
waitDelay time.Duration
clock func() time.Time
pub notify.Publisher
log *zap.Logger
mu sync.Mutex
queues map[matchKey][]uuid.UUID
queued map[uuid.UUID]matchKey
waitingSince map[uuid.UUID]time.Time
results map[uuid.UUID]game.Game
rng *rand.Rand
games GameMatcher
robots RobotProvider
minWait time.Duration
jitter time.Duration
clock func() time.Time
pub notify.Publisher
log *zap.Logger
}
// NewMatchmaker constructs a Matchmaker that starts matched games through games
// and substitutes a robot from robots when a player waits longer than waitDelay.
func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Duration, log *zap.Logger) *Matchmaker {
// NewMatchmaker constructs a Matchmaker that opens auto-match games through games and,
// after a per-game wait of minWait plus a random jitter in [0, jitter), substitutes a
// pooled robot from robots when no human has joined.
func NewMatchmaker(games GameMatcher, robots RobotProvider, minWait, jitter time.Duration, log *zap.Logger) *Matchmaker {
if log == nil {
log = zap.NewNop()
}
return &Matchmaker{
games: games,
robots: robots,
waitDelay: waitDelay,
clock: func() time.Time { return time.Now().UTC() },
pub: notify.Nop{},
log: log,
queues: make(map[matchKey][]uuid.UUID),
queued: make(map[uuid.UUID]matchKey),
waitingSince: make(map[uuid.UUID]time.Time),
results: make(map[uuid.UUID]game.Game),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
games: games,
robots: robots,
minWait: minWait,
jitter: jitter,
clock: func() time.Time { return time.Now().UTC() },
pub: notify.Nop{},
log: log,
}
}
// SetNotifier installs the live-event publisher used to push match_found to the
// seated players when a pairing or robot substitution starts a game. It must be
// called during startup wiring, before the reaper runs; the default is
// notify.Nop (no live events; waiters still discover the game via Poll).
// SetNotifier installs the live-event publisher used to push opponent_joined to a
// waiting starter when a human or a robot takes the empty seat. It must be called
// during startup wiring, before the reaper runs; the default is notify.Nop (no live
// events).
func (m *Matchmaker) SetNotifier(p notify.Publisher) {
if p != nil {
m.pub = p
}
}
// emitMatchFound pushes match_found to every seat of a freshly started game.
// Emitting to a robot seat is harmless (no client subscription exists for it).
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) {
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot
intents := make([]notify.Intent, 0, len(g.Seats))
for _, s := range g.Seats {
state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
if err != nil {
// A waiter still discovers the game through Poll (the ws-down fallback), so skip the
// enriched push for this seat rather than failing the match.
m.log.Warn("match_found initial state",
zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err))
continue
}
mf := notify.MatchFound(s.AccountID, g.ID, state)
mf.Language = lang
intents = append(intents, mf)
}
m.pub.Publish(intents...)
}
// EnqueueResult reports the outcome of joining the pool: either a started game or a
// queued ticket awaiting an opponent.
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
// in, and whether it already had an opponent (they joined a waiting game) rather than
// being freshly opened and still awaiting one.
type EnqueueResult struct {
Matched bool
Game game.Game
}
// Enqueue joins accountID to the auto-match pool for variant under the chosen
// per-turn word rule (multipleWords). If an opponent already waits for the same
// variant and rule, the two are paired (seat order randomised for first-move
// fairness) and a game starts immediately; otherwise the account waits, and a later
// pairing or robot substitution is delivered through Poll. An account already waiting
// in any pool gets ErrAlreadyQueued.
// Enqueue resolves an auto-match request for accountID under variant and the per-turn
// word rule (multipleWords) into the game they enter immediately — a freshly opened
// game awaiting an opponent, the caller's own still-open game (a re-enqueue is
// idempotent), or another player's open game they just joined. When the caller joins
// an existing game, opponent_joined is pushed to that game's waiting starter.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
key := matchKey{variant: variant, multipleWords: multipleWords}
m.mu.Lock()
if _, ok := m.queued[accountID]; ok {
m.mu.Unlock()
return EnqueueResult{}, ErrAlreadyQueued
}
q := m.queues[key]
if len(q) == 0 {
m.queues[key] = append(q, accountID)
m.queued[accountID] = key
m.waitingSince[accountID] = m.clock()
m.mu.Unlock()
return EnqueueResult{}, nil
}
opponent := q[0]
m.removeLocked(opponent, key)
seats := []uuid.UUID{opponent, accountID}
if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[0]
}
m.mu.Unlock()
g, err := m.games.Create(ctx, autoMatchParams(key, seats))
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
if err != nil {
return EnqueueResult{}, err
}
// The opponent was waiting; record the game so they can collect it via Poll.
m.mu.Lock()
m.results[opponent] = g
m.mu.Unlock()
m.emitMatchFound(ctx, g)
return EnqueueResult{Matched: true, Game: g}, nil
}
// Poll reports whether accountID has been matched since it queued, returning the
// started game once (the result is drained on read). It reports Matched=false
// while the account is still waiting or has no pending result.
func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
if g, ok := m.results[accountID]; ok {
delete(m.results, accountID)
return EnqueueResult{Matched: true, Game: g}, nil
if joined {
m.announceOpponent(ctx, g, accountID)
}
return EnqueueResult{}, nil
return EnqueueResult{Matched: joined, Game: g}, nil
}
// Cancel removes accountID from whatever pool it waits in and drops any pending
// matched result, reporting whether it was queued. Clearing the result closes the
// race where the reaper substituted a robot just before the player cancelled: the
// stale game must not later surface through Poll as a game the player did not want.
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.results, accountID)
key, ok := m.queued[accountID]
if !ok {
return false
}
m.removeLocked(accountID, key)
return true
}
// QueueLen returns the number of accounts waiting in the variant pool, summed across
// both per-turn word rules.
func (m *Matchmaker) QueueLen(variant engine.Variant) int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.queues[matchKey{variant: variant, multipleWords: false}]) +
len(m.queues[matchKey{variant: variant, multipleWords: true}])
}
// RunReaper substitutes a robot for any player that has waited past waitDelay,
// scanning every interval until ctx is cancelled. It is started once from main.
// RunReaper substitutes a robot for any open game past its wait window, scanning every
// interval until ctx is cancelled. It is started once from main.
func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -203,77 +111,83 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
}
}
// Reap pairs every player that has waited past waitDelay with a freshly picked
// robot and starts the game, recording it for the player's Poll. RunReaper calls
// it on a timer; it takes now explicitly so tests and ops can drive a single pass
// at a chosen instant. A waiter is only dequeued once a robot is secured, so a
// momentarily empty pool just defers substitution to a later tick.
// Reap substitutes a robot into every open game whose wait window elapsed by now and
// pushes opponent_joined to its starter. RunReaper calls it on a timer; it takes now
// explicitly so tests and ops can drive a single pass at a chosen instant. A game for
// which no robot is available is left for a later tick.
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
type sub struct {
human uuid.UUID
key matchKey
seats []uuid.UUID
due, err := m.games.ExpiredOpen(ctx, now)
if err != nil {
m.log.Warn("scan open games", zap.Error(err))
return
}
m.mu.Lock()
var due []uuid.UUID
for acc, since := range m.waitingSince {
if now.Sub(since) >= m.waitDelay {
due = append(due, acc)
}
}
var subs []sub
for _, acc := range due {
key := m.queued[acc]
robotID, err := m.robots.Pick(key.variant)
for _, og := range due {
robotID, err := m.robots.Pick(og.Variant)
if err != nil {
m.log.Warn("robot substitution deferred", zap.Error(err))
continue
}
m.removeLocked(acc, key)
seats := []uuid.UUID{acc, robotID}
if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[0]
}
subs = append(subs, sub{human: acc, key: key, seats: seats})
}
m.mu.Unlock()
for _, s := range subs {
g, err := m.games.Create(ctx, autoMatchParams(s.key, s.seats))
g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID)
if err != nil {
m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err))
m.log.Warn("robot substitution failed", zap.String("game", og.ID.String()), zap.Error(err))
continue
}
m.mu.Lock()
m.results[s.human] = g
m.mu.Unlock()
m.emitMatchFound(ctx, g)
if !attached {
continue // a human joined first between the scan and the substitution
}
m.announceOpponent(ctx, g, robotID)
}
}
// removeLocked drops accountID from the queue, the queued index and the waiting
// clock. The caller holds m.mu.
func (m *Matchmaker) removeLocked(accountID uuid.UUID, key matchKey) {
delete(m.queued, accountID)
delete(m.waitingSince, accountID)
q := m.queues[key]
for i, id := range q {
if id == accountID {
m.queues[key] = append(q[:i], q[i+1:]...)
break
// announceOpponent pushes opponent_joined to the game's waiting starter — the seat
// that is not joinerID — so its client fills the opponent card and re-enables resign
// and chat in place. Routed by the game's language, like every game push.
func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID uuid.UUID) {
starter, ok := otherSeat(g, joinerID)
if !ok {
return
}
state, err := m.games.InitialState(ctx, g.ID, starter)
if err != nil {
m.log.Warn("opponent_joined initial state",
zap.String("game", g.ID.String()), zap.String("account", starter.String()), zap.Error(err))
return
}
intent := notify.OpponentJoined(starter, g.ID, state)
intent.Language = g.Variant.Language()
m.pub.Publish(intent)
}
// openDeadline is when the reaper substitutes a robot for a game opened now: a fixed
// minimum wait plus a random jitter, so the substitution time varies per game.
func (m *Matchmaker) openDeadline() time.Time {
d := m.minWait
if m.jitter > 0 {
d += rand.N(m.jitter)
}
return m.clock().Add(d)
}
// otherSeat returns the account at the seat that is not accountID — the open game's
// starter when accountID is the joiner — and false when no seat differs or it is still
// empty.
func otherSeat(g game.Game, accountID uuid.UUID) (uuid.UUID, bool) {
for _, s := range g.Seats {
if s.AccountID != accountID && s.AccountID != uuid.Nil {
return s.AccountID, true
}
}
return uuid.Nil, false
}
// autoMatchParams builds the create parameters for a two-player auto-match with
// the casual defaults.
func autoMatchParams(key matchKey, seats []uuid.UUID) game.CreateParams {
// autoMatchParams builds the create parameters for a two-player auto-match with the
// casual defaults; the game service assembles the seats and pins the bag seed.
func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
return game.CreateParams{
Variant: key.variant,
Seats: seats,
Variant: variant,
TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
MultipleWordsPerTurn: key.multipleWords,
MultipleWordsPerTurn: multipleWords,
}
}
+134 -268
View File
@@ -14,28 +14,51 @@ import (
"scrabble/backend/internal/notify"
)
// fakeCreator records the games a matchmaker asks it to start.
type fakeCreator struct {
created []game.CreateParams
err error
// stubMatcher is a fake GameMatcher: it returns canned games and records the calls the
// matchmaker makes, so the unit tests cover delegation, the opponent_joined emit and
// the wait-window math without a database. The DB-backed open/join/substitute logic is
// covered by the integration suite.
type stubMatcher struct {
openGame game.Game
openJoined bool
openErr error
openCalls int
lastDeadline time.Time
expired []game.OpenGame
attachGame game.Game
attached bool
attachErr error
attachedGames []uuid.UUID
}
func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) {
if f.err != nil {
return game.Game{}, f.err
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
s.openCalls++
s.lastDeadline = deadline
return s.openGame, s.openJoined, s.openErr
}
func (s *stubMatcher) AttachRobot(_ context.Context, gameID, _ uuid.UUID) (game.Game, bool, error) {
if s.attachErr != nil {
return game.Game{}, false, s.attachErr
}
f.created = append(f.created, p)
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
if s.attached {
s.attachedGames = append(s.attachedGames, gameID)
}
return s.attachGame, s.attached, nil
}
// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing
// tests assert on matching behaviour, not the payload, so an empty state is enough.
func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
func (s *stubMatcher) ExpiredOpen(_ context.Context, _ time.Time) ([]game.OpenGame, error) {
return s.expired, nil
}
func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
return notify.PlayerState{}, nil
}
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
// an empty pool. It records the variant of the last substitution request.
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an
// empty pool. It records the variant of the last substitution request.
type fakeRobots struct {
id uuid.UUID
err error
@@ -50,294 +73,137 @@ func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
return f.id, nil
}
// testWaitDelay is long enough that the reaper never fires in the pairing tests
// (which do not run it); the substitution tests drive reap directly.
const testWaitDelay = 10 * time.Second
// capturePub records every published intent.
type capturePub struct{ intents []notify.Intent }
func newTestMatchmaker(creator GameCreator, robotID uuid.UUID) *Matchmaker {
return NewMatchmaker(creator, &fakeRobots{id: robotID}, testWaitDelay, zap.NewNop())
}
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
for _, w := range want {
found := false
for _, s := range seats {
if s == w {
found = true
break
}
}
if !found {
return false
}
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
// (uuid.Nil for a still-empty opponent seat).
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
return game.Game{
ID: uuid.New(),
Variant: engine.VariantEnglish,
Seats: []game.Seat{
{Seat: 0, AccountID: starter},
{Seat: 1, AccountID: opponent},
},
}
return true
}
func TestMatchmakerPairsTwoHumans(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
a, b := uuid.New(), uuid.New()
func TestEnqueueOpensGameWithoutOpponent(t *testing.T) {
starter := uuid.New()
m := &stubMatcher{openGame: twoSeatGame(starter, uuid.Nil)}
pub := &capturePub{}
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
mm.SetNotifier(pub)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
res, err := mm.Enqueue(context.Background(), starter, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
t.Fatalf("enqueue: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must wait, not match")
if res.Matched {
t.Error("opening a game must report Matched=false")
}
if mm.QueueLen(engine.VariantEnglish) != 1 {
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
if m.openCalls != 1 {
t.Errorf("OpenOrJoin calls = %d, want 1", m.openCalls)
}
if len(pub.intents) != 0 {
t.Errorf("opening a game must not emit opponent_joined; got %d intents", len(pub.intents))
}
}
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
func TestEnqueueJoinEmitsOpponentJoinedToStarter(t *testing.T) {
starter, joiner := uuid.New(), uuid.New()
m := &stubMatcher{openGame: twoSeatGame(starter, joiner), openJoined: true}
pub := &capturePub{}
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
mm.SetNotifier(pub)
res, err := mm.Enqueue(context.Background(), joiner, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if !r2.Matched {
t.Fatal("second enqueue must match")
}
if mm.QueueLen(engine.VariantEnglish) != 0 {
t.Fatalf("queue len = %d, want 0 after match", mm.QueueLen(engine.VariantEnglish))
}
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1", len(creator.created))
}
p := creator.created[0]
if len(p.Seats) != 2 || !seatsContain(p.Seats, a, b) {
t.Errorf("seats = %v, want both %s and %s", p.Seats, a, b)
}
if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer {
t.Errorf("auto-match defaults not applied: %+v", p)
}
// The waiting opponent learns of the match through Poll, exactly once.
got, err := mm.Poll(ctx, a)
if err != nil {
t.Fatalf("poll a: %v", err)
}
if !got.Matched || got.Game.ID != r2.Game.ID {
t.Errorf("poll a = %+v, want the matched game %s", got, r2.Game.ID)
}
if again, _ := mm.Poll(ctx, a); again.Matched {
t.Error("poll result must drain after the first read")
}
}
func TestMatchmakerAlreadyQueued(t *testing.T) {
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); !errors.Is(err, ErrAlreadyQueued) {
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
if !res.Matched {
t.Error("joining a waiting game must report Matched=true")
}
if len(pub.intents) != 1 {
t.Fatalf("joining must emit one opponent_joined; got %d", len(pub.intents))
}
if got := pub.intents[0]; got.Kind != notify.KindOpponentJoined || got.UserID != starter {
t.Errorf("opponent_joined = (kind %q, user %s), want (%q, starter %s)", got.Kind, got.UserID, notify.KindOpponentJoined, starter)
}
}
func TestMatchmakerCancel(t *testing.T) {
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
if !mm.Cancel(ctx, a) {
t.Fatal("cancel of a queued account must report true")
}
if mm.QueueLen(engine.VariantEnglish) != 0 {
t.Fatalf("queue len = %d, want 0 after cancel", mm.QueueLen(engine.VariantEnglish))
}
if mm.Cancel(ctx, a) {
t.Fatal("cancel of an unqueued account must report false")
}
}
func TestMatchmakerVariantsAreSeparate(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue en: %v", err)
}
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil {
t.Fatalf("enqueue ru: %v", err)
}
if len(creator.created) != 0 {
t.Fatalf("different variants must not match; created %d", len(creator.created))
}
if mm.QueueLen(engine.VariantEnglish) != 1 || mm.QueueLen(engine.VariantRussianScrabble) != 1 {
t.Errorf("each variant pool should hold one waiter")
}
}
func TestMatchmakerFIFO(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
a, b, c := uuid.New(), uuid.New(), uuid.New()
for _, id := range []uuid.UUID{a, b, c} {
if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue %s: %v", id, err)
}
}
// a waited, b matched a (oldest), c waits.
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1", len(creator.created))
}
if !seatsContain(creator.created[0].Seats, a, b) {
t.Errorf("FIFO match should pair a and b, got %v", creator.created[0].Seats)
}
if mm.QueueLen(engine.VariantEnglish) != 1 {
t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish))
}
}
func TestMatchmakerReaperSubstitutesRobot(t *testing.T) {
creator := &fakeCreator{}
robotID := uuid.New()
mm := newTestMatchmaker(creator, robotID)
func TestEnqueueDeadlineWithinWindow(t *testing.T) {
base := time.Now()
m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)}
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, 90*time.Second, 90*time.Second, zap.NewNop())
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
if _, err := mm.Enqueue(context.Background(), uuid.New(), engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(5*time.Second)) // before the wait window
if len(creator.created) != 0 || mm.QueueLen(engine.VariantEnglish) != 1 {
t.Fatalf("must not substitute before the wait: created=%d queued=%d", len(creator.created), mm.QueueLen(engine.VariantEnglish))
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // past the wait window
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1 after substitution", len(creator.created))
}
if !seatsContain(creator.created[0].Seats, a, robotID) {
t.Errorf("substituted game seats = %v, want human %s and robot %s", creator.created[0].Seats, a, robotID)
}
if mm.QueueLen(engine.VariantEnglish) != 0 {
t.Errorf("waiter should be dequeued after substitution")
}
got, err := mm.Poll(ctx, a)
if err != nil || !got.Matched {
t.Errorf("poll after substitution = %+v err=%v, want matched", got, err)
lo, hi := base.Add(90*time.Second), base.Add(180*time.Second)
if m.lastDeadline.Before(lo) || !m.lastDeadline.Before(hi) {
t.Errorf("deadline %s not in [%s, %s)", m.lastDeadline, lo, hi)
}
}
func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
func TestReapSubstitutesRobotAndEmits(t *testing.T) {
human, robotID := uuid.New(), uuid.New()
og := game.OpenGame{ID: uuid.New(), Variant: engine.VariantRussianScrabble}
m := &stubMatcher{
expired: []game.OpenGame{og},
attachGame: twoSeatGame(human, robotID),
attached: true,
}
mm.Cancel(ctx, a)
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
if len(creator.created) != 0 {
t.Errorf("a cancelled waiter must not be substituted; created %d", len(creator.created))
robots := &fakeRobots{id: robotID}
pub := &capturePub{}
mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop())
mm.SetNotifier(pub)
mm.Reap(context.Background(), time.Now())
if robots.lastVariant != engine.VariantRussianScrabble {
t.Errorf("robot picked for %v, want the open game's variant", robots.lastVariant)
}
if len(m.attachedGames) != 1 || m.attachedGames[0] != og.ID {
t.Errorf("attached games = %v, want [%s]", m.attachedGames, og.ID)
}
if len(pub.intents) != 1 || pub.intents[0].Kind != notify.KindOpponentJoined || pub.intents[0].UserID != human {
t.Errorf("reap must emit opponent_joined to the human starter; got %+v", pub.intents)
}
}
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
// robot just before the player cancels: Cancel must drop the pending result so the
// abandoned game never surfaces through Poll.
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
func TestReapDefersWithoutRobot(t *testing.T) {
m := &stubMatcher{expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}}
pub := &capturePub{}
mm := NewMatchmaker(m, &fakeRobots{err: errors.New("empty pool")}, time.Minute, time.Minute, zap.NewNop())
mm.SetNotifier(pub)
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
mm.Reap(context.Background(), time.Now())
if len(m.attachedGames) != 0 {
t.Errorf("no robot available: must not attach; attached %v", m.attachedGames)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
mm.Cancel(ctx, a) // ... then the player cancels
if got, _ := mm.Poll(ctx, a); got.Matched {
t.Error("cancel must drop the pending substituted game; Poll still matched")
if len(pub.intents) != 0 {
t.Errorf("no robot available: must not emit; got %d intents", len(pub.intents))
}
}
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
func TestReapSkipsWhenHumanJoinedFirst(t *testing.T) {
m := &stubMatcher{
expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}},
attached: false, // AttachRobot reports the game already filled by a human
}
pub := &capturePub{}
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
mm.SetNotifier(pub)
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
if len(creator.created) != 0 {
t.Errorf("no robot available: must not create a game; created %d", len(creator.created))
}
if mm.QueueLen(engine.VariantEnglish) != 1 {
t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish))
}
}
// TestMatchmakerRulesAreSeparate confirms two players who chose the same variant but a
// different per-turn word rule are not paired, and that the rule reaches the started game.
func TestMatchmakerRulesAreSeparate(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
// Same variant, opposite rules: they must not match.
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil {
t.Fatalf("enqueue single-word: %v", err)
}
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil {
t.Fatalf("enqueue standard: %v", err)
}
if len(creator.created) != 0 {
t.Fatalf("different rules must not match; created %d", len(creator.created))
}
// A second single-word player pairs with the first; the game carries the rule.
r, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false)
if err != nil {
t.Fatalf("enqueue single-word opponent: %v", err)
}
if !r.Matched {
t.Fatal("same variant and rule must match")
}
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1", len(creator.created))
}
if creator.created[0].MultipleWordsPerTurn {
t.Error("single-word match must create a game with MultipleWordsPerTurn=false")
}
}
// TestMatchmakerReaperKeepsRule confirms a robot substitution carries the waiter's rule.
func TestMatchmakerReaperKeepsRule(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1", len(creator.created))
}
if creator.created[0].MultipleWordsPerTurn {
t.Error("robot substitution must keep the waiter's single-word rule")
mm.Reap(context.Background(), time.Now())
if len(pub.intents) != 0 {
t.Errorf("a human-filled game must not emit opponent_joined; got %d", len(pub.intents))
}
}
+16
View File
@@ -122,6 +122,22 @@ func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()}
}
// OpponentJoined tells userID — the starter of an auto-match game still shown as
// "searching for an opponent" — that an opponent (a human or a substituted robot) has
// taken the empty seat. state is the starter's refreshed view (now seating both
// players), so the client fills the opponent card and re-enables resign and chat in
// place without navigating. It reuses the match_found payload layout (game id + state).
func OpponentJoined(userID, gameID uuid.UUID, state PlayerState) Intent {
b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String())
stateOff := buildStateView(b, state)
fb.MatchFoundEventStart(b)
fb.MatchFoundEventAddGameId(b, gid)
fb.MatchFoundEventAddState(b, stateOff)
b.Finish(fb.MatchFoundEventEnd(b))
return Intent{UserID: userID, Kind: KindOpponentJoined, Payload: b.FinishedBytes(), EventID: eventID()}
}
// Notification is a lightweight "re-poll" signal to userID that something in their lobby
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
+5
View File
@@ -24,6 +24,11 @@ const (
KindChatMessage = "chat_message"
KindNudge = "nudge"
KindMatchFound = "match_found"
// KindOpponentJoined tells the starter of an auto-match game still "searching for an
// opponent" that the empty seat has been taken (by a human or a substituted robot),
// carrying the refreshed StateView so the client fills the opponent card and
// re-enables resign and chat in place. In-app only (never an out-of-app push).
KindOpponentJoined = "opponent_joined"
// KindNotification is a lightweight "re-poll your lobby counters" signal
// (incoming friend requests, invitations) that drives the lobby badge.
KindNotification = "notify"
@@ -14,7 +14,7 @@ import (
type GamePlayers struct {
GameID uuid.UUID `sql:"primary_key"`
Seat int16 `sql:"primary_key"`
AccountID uuid.UUID
AccountID *uuid.UUID
Score int32
HintsUsed int16
IsWinner bool
@@ -29,6 +29,7 @@ type Games struct {
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
OpenDeadlineAt *time.Time
DropoutTiles string
MultipleWordsPerTurn bool
}
@@ -33,6 +33,7 @@ type gamesTable struct {
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
OpenDeadlineAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
@@ -92,10 +93,11 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
OpenDeadlineAtColumn = postgres.TimestampzColumn("open_deadline_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
)
@@ -119,6 +121,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
OpenDeadlineAt: OpenDeadlineAtColumn,
DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
@@ -96,10 +96,14 @@ CREATE TABLE games (
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
-- open_deadline_at is set only while status='open' (an auto-match game awaiting an
-- opponent): the instant the matchmaking reaper substitutes a robot if no human has
-- joined by then. NULL for every active and finished game.
open_deadline_at timestamptz,
dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true,
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished', 'open')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
@@ -112,15 +116,19 @@ CREATE TABLE games (
-- The sweeper scans active games oldest-turn-first; a partial index keeps it off the
-- finished archive.
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
-- The matchmaking reaper scans open games due for a robot substitution; a partial index
-- keeps it off the active and finished games.
CREATE INDEX games_open_idx ON games (open_deadline_at) WHERE status = 'open';
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
-- durable account. score is the running/final score, is_winner is stamped on finish
-- (false for every seat on a draw), hints_used counts the per-game allowance consumed
-- before the profile wallet.
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a durable
-- account, or NULL for the still-empty opponent seat of an auto-match game waiting for an
-- opponent (status='open'); it is filled when a human or a robot joins. score is the
-- running/final score, is_winner is stamped on finish (false for every seat on a draw),
-- hints_used counts the per-game allowance consumed before the profile wallet.
CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (account_id),
account_id uuid REFERENCES accounts (account_id),
score integer NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0,
is_winner boolean NOT NULL DEFAULT false,
+14 -6
View File
@@ -1,6 +1,8 @@
package server
import (
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
@@ -186,9 +188,16 @@ const awayTimeLayout = "15:04"
func gameDTOFromGame(g game.Game) gameDTO {
seats := make([]seatDTO, 0, len(g.Seats))
for _, s := range g.Seats {
// An open game's still-empty opponent seat has no account: emit an empty id (the
// display name is left empty by fillSeatNames) so the client shows "searching for
// opponent" rather than the nil-UUID.
accountID := ""
if s.AccountID != uuid.Nil {
accountID = s.AccountID.String()
}
seats = append(seats, seatDTO{
Seat: s.Seat,
AccountID: s.AccountID.String(),
AccountID: accountID,
Score: s.Score,
HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner,
@@ -277,13 +286,12 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
return dto, nil
}
// matchDTOFrom projects an enqueue/poll result into its DTO.
// matchDTOFrom projects an enqueue result into its DTO. Enqueue always lands the
// caller in a game (freshly opened or joined), so the game is always present; Matched
// reports whether it already had an opponent.
func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
if !r.Matched {
return matchDTO{Matched: false}
}
g := gameDTOFromGame(r.Game)
return matchDTO{Matched: true, Game: &g}
return matchDTO{Matched: r.Matched, Game: &g}
}
// chatDTOFrom projects a chat message into its DTO.
+2 -4
View File
@@ -76,8 +76,6 @@ func (s *Server) registerRoutes() {
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
u.POST("/lobby/cancel", s.handleCancel)
u.GET("/lobby/poll", s.handlePoll)
}
if s.invitations != nil {
u.GET("/invitations", s.handleListInvitations)
@@ -161,14 +159,14 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "nudge_own_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished"
case errors.Is(err, game.ErrNoOpponentYet):
return http.StatusConflict, "no_opponent_yet"
case errors.Is(err, game.ErrGameActive):
return http.StatusConflict, "game_active"
case errors.Is(err, account.ErrInvalidProfile):
return http.StatusBadRequest, "invalid_profile"
case errors.Is(err, account.ErrAlreadyConfirmed):
return http.StatusConflict, "already_confirmed"
case errors.Is(err, lobby.ErrAlreadyQueued):
return http.StatusConflict, "already_queued"
case errors.Is(err, lobby.ErrInvalidInvitation):
return http.StatusBadRequest, "invalid_invitation"
case errors.Is(err, lobby.ErrInvitationBlocked):
+4 -35
View File
@@ -133,13 +133,15 @@ func (s *Server) handleGameState(c *gin.Context) {
c.JSON(http.StatusOK, dto)
}
// enqueueRequest joins the per-variant auto-match pool under a per-turn word rule.
// enqueueRequest enters per-variant auto-match under a per-turn word rule.
type enqueueRequest struct {
Variant string `json:"variant"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
}
// handleEnqueue joins the auto-match pool for a variant.
// handleEnqueue enters the caller into auto-match for a variant and returns the game
// they land in immediately: a freshly opened game awaiting an opponent, or another
// player's open game they just joined. The client navigates straight into the game.
func (s *Server) handleEnqueue(c *gin.Context) {
uid, ok := userID(c)
if !ok {
@@ -168,39 +170,6 @@ func (s *Server) handleEnqueue(c *gin.Context) {
c.JSON(http.StatusOK, dto)
}
// handleCancel removes the caller from the auto-match pool (and drops any pending
// matched result), so a cancelled quick-match neither blocks a re-queue nor later
// surfaces a robot-substituted game the player abandoned. It is idempotent: cancelling
// when not queued is a no-op success.
func (s *Server) handleCancel(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
s.matchmaker.Cancel(c.Request.Context(), uid)
c.Status(http.StatusNoContent)
}
// handlePoll reports whether the caller has been paired since queueing.
func (s *Server) handlePoll(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
res, err := s.matchmaker.Poll(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
dto := matchDTOFrom(res)
if dto.Game != nil {
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{})
}
c.JSON(http.StatusOK, dto)
}
// chatPostRequest posts a per-game chat message.
type chatPostRequest struct {
Body string `json:"body"`