diff --git a/PRERELEASE.md b/PRERELEASE.md
index 2617e5c..a18868f 100644
--- a/PRERELEASE.md
+++ b/PRERELEASE.md
@@ -26,6 +26,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| R7 | Final stress run + tuning | 9b | **done** |
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
+| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
@@ -69,6 +70,14 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** —
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
**no auto-ban**.
+- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
+ it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
+ joins it, or a robot fills it after a **90 s + random 0–90 s** wait, pushing the in-app
+ **opponent_joined** event. While open, the starter may move on their turn but resign, chat and
+ nudge are disabled, and the lobby + opponent card read "searching for opponent". Matchmaking is
+ now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
+ schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
+ empty seat.
## Phases
diff --git a/backend/README.md b/backend/README.md
index a02b99c..ef038c2 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -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 2–4 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 **0–90 s** (so **90–180 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)
diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index 63b0583..5d559d6 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -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.
diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go
index b266e47..96955f5 100644
--- a/backend/internal/adminconsole/render_test.go
+++ b/backend/internal/adminconsole/render_test.go
@@ -31,6 +31,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
FlagThreshold: 1000, FlagWindow: "10m0s",
}, "Recent episodes"},
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "scrabble_en", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
+ {"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
diff --git a/backend/internal/adminconsole/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml
index 2e21b88..4a958fd 100644
--- a/backend/internal/adminconsole/templates/pages/games.gohtml
+++ b/backend/internal/adminconsole/templates/pages/games.gohtml
@@ -3,6 +3,7 @@
{{with .Data}}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 10151e0..d7151d6 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -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
}
diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go
index dd266bf..9a8106e 100644
--- a/backend/internal/game/eventwire.go
+++ b/backend/internal/game/eventwire.go
@@ -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,
diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go
index fc726c8..971822f 100644
--- a/backend/internal/game/service.go
+++ b/backend/internal/game/service.go
@@ -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 {
diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go
index a1a9d44..1ed55ef 100644
--- a/backend/internal/game/store.go
+++ b/backend/internal/game/store.go
@@ -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,
diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go
index 5b7684f..d5fe2f5 100644
--- a/backend/internal/game/types.go
+++ b/backend/internal/game/types.go
@@ -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) {
diff --git a/backend/internal/inttest/helpers.go b/backend/internal/inttest/helpers.go
index fc94a8f..d574dab 100644
--- a/backend/internal/inttest/helpers.go
+++ b/backend/internal/inttest/helpers.go
@@ -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.
diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go
index 6025cf2..859be04 100644
--- a/backend/internal/inttest/lobby_test.go
+++ b/backend/internal/inttest/lobby_test.go
@@ -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)
}
}
diff --git a/backend/internal/inttest/open_match_test.go b/backend/internal/inttest/open_match_test.go
new file mode 100644
index 0000000..4f7f272
--- /dev/null
+++ b/backend/internal/inttest/open_match_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go
index 15eda3f..c653674 100644
--- a/backend/internal/inttest/robot_test.go
+++ b/backend/internal/inttest/robot_test.go
@@ -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)
}
diff --git a/backend/internal/lobby/config.go b/backend/internal/lobby/config.go
index a016b43..733364c 100644
--- a/backend/internal/lobby/config.go
+++ b/backend/internal/lobby/config.go
@@ -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 (90–180 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)
}
diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go
index b627b68..598ddab 100644
--- a/backend/internal/lobby/lobby.go
+++ b/backend/internal/lobby/lobby.go
@@ -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")
diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go
index 58de1bc..6456680 100644
--- a/backend/internal/lobby/matchmaker.go
+++ b/backend/internal/lobby/matchmaker.go
@@ -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,
}
}
diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go
index 6a7374e..ad5bb9b 100644
--- a/backend/internal/lobby/matchmaker_test.go
+++ b/backend/internal/lobby/matchmaker_test.go
@@ -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))
}
}
diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go
index 4e41ced..970fda5 100644
--- a/backend/internal/notify/events.go
+++ b/backend/internal/notify/events.go
@@ -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
diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go
index d96887d..ce34df8 100644
--- a/backend/internal/notify/notify.go
+++ b/backend/internal/notify/notify.go
@@ -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"
diff --git a/backend/internal/postgres/jet/backend/model/game_players.go b/backend/internal/postgres/jet/backend/model/game_players.go
index 0135af9..8018a41 100644
--- a/backend/internal/postgres/jet/backend/model/game_players.go
+++ b/backend/internal/postgres/jet/backend/model/game_players.go
@@ -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
diff --git a/backend/internal/postgres/jet/backend/model/games.go b/backend/internal/postgres/jet/backend/model/games.go
index 8b254b4..b4a7d2f 100644
--- a/backend/internal/postgres/jet/backend/model/games.go
+++ b/backend/internal/postgres/jet/backend/model/games.go
@@ -29,6 +29,7 @@ type Games struct {
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
+ OpenDeadlineAt *time.Time
DropoutTiles string
MultipleWordsPerTurn bool
}
diff --git a/backend/internal/postgres/jet/backend/table/games.go b/backend/internal/postgres/jet/backend/table/games.go
index 8848c44..bfe8e7c 100644
--- a/backend/internal/postgres/jet/backend/table/games.go
+++ b/backend/internal/postgres/jet/backend/table/games.go
@@ -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,
diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql
index 4bba8bd..2a0edd1 100644
--- a/backend/internal/postgres/migrations/00001_baseline.sql
+++ b/backend/internal/postgres/migrations/00001_baseline.sql
@@ -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,
diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go
index 8796720..87eee89 100644
--- a/backend/internal/server/dto.go
+++ b/backend/internal/server/dto.go
@@ -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.
diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go
index 7537f6e..b8c83bc 100644
--- a/backend/internal/server/handlers.go
+++ b/backend/internal/server/handlers.go
@@ -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):
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index e98c3f1..fe63d53 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -678,7 +678,7 @@ func consolePage(c *gin.Context) int {
// normalizeGameStatus keeps only a recognised game status filter, else "" (all).
func normalizeGameStatus(s string) string {
switch s {
- case game.StatusActive, game.StatusFinished:
+ case game.StatusActive, game.StatusFinished, game.StatusOpen:
return s
}
return ""
diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go
index 834aa0a..798fd14 100644
--- a/backend/internal/server/handlers_user.go
+++ b/backend/internal/server/handlers_user.go
@@ -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"`
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 4fcccb4..1d27b84 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -334,10 +334,11 @@ Key points:
## 7. Robot opponent
-Substitutes for a human in 2-player auto-match when the pool yields no human
-within 10 seconds (§8). It lives in `internal/robot` and plays as an ordinary
-seated account through the game service, so only `internal/engine` imports the
-solver. It is designed to be indistinguishable from a person.
+Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an
+open game's empty opponent slot when no human has joined within the wait window (§8).
+It lives in `internal/robot` and plays as an ordinary seated account through the game
+service, so only `internal/engine` imports the solver. It is designed to be
+indistinguishable from a person.
The robot keeps **no per-game state**: every choice is derived deterministically
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
@@ -381,17 +382,24 @@ English game the Latin pool.
## 8. Lobby & social
-- **Matchmaking**: an **in-memory** FIFO pool keyed by `variant` (the variant
- fixes the board language), pairing the next two humans into a two-player
- auto-match with the seat order randomised for first-move fairness. The pool is
- lost on restart (players re-queue) and is anonymous, so it does not consult
- blocks. After **10 s** with no human a background reaper substitutes a pooled
- robot (§7) and starts the game. On a pairing or substitution the matchmaker
- emits a **match-found** notification (§10), delivered over the live stream;
- `Poll` remains as a fallback for a client that is not currently streaming.
- **Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
- pending matched result, so a cancelled quick-match is dequeued rather than left for
- the reaper to robot-substitute.
+- **Matchmaking**: auto-match drops the player **straight into a real game and lets
+ them wait inside it**. `Enqueue` (`POST /lobby/enqueue`) opens a game seating the
+ caller with an **empty opponent seat** (status `open`, §9), or — when another player
+ is already waiting for the same `variant` and per-turn rule — seats the caller into
+ that open game and starts it; which seat the caller takes is randomised for
+ first-move fairness, and a re-enqueue returns the caller's own still-open game
+ (idempotent). Matchmaking state is therefore the **open games in the database** (not
+ an in-memory pool), so it survives a restart and stays anonymous (no block check);
+ concurrent enqueues for one bucket are serialised by a transaction-scoped advisory
+ lock so two callers pair rather than each opening a game. A background **reaper**
+ seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus
+ a random **0–90 s** (so **90–180 s** total) — has elapsed, guaranteeing every game
+ gets an opponent. When a human or a robot takes the seat, the waiting starter
+ receives an **opponent-joined** notification (§10) that fills the opponent card and
+ re-enables resign and chat **in place** — the starter never leaves the game. While a
+ game is `open` the starter may move on their turn, but resign, chat and nudge are
+ refused (no opponent yet) and the lobby and opponent card show a "searching for
+ opponent" placeholder.
- **Friends**: two add paths over one `friendships` table. A **one-time
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
@@ -462,7 +470,10 @@ English game the Latin pool.
game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one
account's own lobby list, leaving it visible to the other players — finished-only and
irreversible by design, so there is no un-hide).
- The matchmaking pool is **in-memory** and persists nothing.
+ Auto-match has no separate store: a game **awaiting an opponent** is an ordinary
+ `games` row with status `open` and a single seated `game_players` row (the empty
+ opponent seat is a null `account_id`, filled when a human or robot joins), plus an
+ `open_deadline_at` stamp the reaper scans for robot substitution.
- **Active games are event-sourced.** A game is a `games` row (pinned
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
@@ -470,7 +481,8 @@ English game the Latin pool.
rebuilt by replaying the journal on a miss, which the seeded bag makes exact.
Each game is serialised by a per-game lock; a persistence failure evicts the
live game so the next access rebuilds from the journal. `game_players` records
- each seat's account, running score, hints used and winner flag.
+ each seat's account (**null for an open game's still-empty opponent seat**),
+ running score, hints used and winner flag.
- **Statistics** (`account_stats`, recomputed on each finish for durable
non-guest accounts only — the finish-time recompute skips any `is_guest`
seat): wins, losses, **draws**, max points in a game, and
@@ -520,7 +532,7 @@ catalog is **your-turn** and **opponent-moved** (emitted from the game commit, s
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
including the mover**, so the mover's own other devices and their lobby refresh — it is
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
-(from the social service), **match-found** (from the matchmaker, §8), and **notify**
+(from the social service), **opponent-joined** (from the matchmaker, §8), and **notify**
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original
@@ -535,16 +547,17 @@ without a follow-up `game.state`: **opponent-moved** carries the committed move
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move
echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history`
-refetch); **your-turn** carries that move count as a consistency check; **match-found** and the
-**game-started** notify carry the recipient's full **initial `StateView`**, so opening a freshly
-started game is instant; **game-over** carries the final summary; the lobby **notify** sub-kinds
+refetch); **your-turn** carries that move count as a consistency check; the **game-started**
+notify carries the recipient's full **initial `StateView`** so opening a freshly started game is
+instant, and **opponent-joined** carries the waiting starter's refreshed `StateView` so the
+opponent card and the resign/chat controls update **in place**; **game-over** carries the final summary; the lobby **notify** sub-kinds
carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
-verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for
-match-found — the client polls **only while the stream is down**, since a live stream delivers
-match-found itself; for the lobby **notification badge** (incoming friend requests + open
+verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters
+synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined**
+event; for the lobby **notification badge** (incoming friend requests + open
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no
@@ -557,7 +570,7 @@ not the recipient's latest-login bot. It then asks the **Telegram connector** to
localized message with a Mini App deep-link button — only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
-your-turn, game-over, nudge, match-found and the invitation / friend-request notify sub-kinds;
+your-turn, game-over, nudge and the invitation / friend-request notify sub-kinds;
the connector renders the message and skips the rest. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 839d17e..6457aa1 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -41,8 +41,9 @@ language, not whichever bot the player signed in through last. Guests are sessio
(auto-match only; no friends, stats or history); an abandoned guest that never
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
keeps a live stream and receives in-app updates in real time — the opponent's move,
-your turn, chat, nudges and a found match. Each update lands as the event itself, applied in place
-with no reload, so the board refreshes seamlessly and a found or invited game opens instantly. When the app is **closed**, the chosen
+your turn, chat, nudges and an opponent joining a game you are waiting in. Each update lands as the
+event itself, applied in place with no reload, so the board refreshes seamlessly and an invited game
+opens instantly. When the app is **closed**, the chosen
out-of-app events (your turn, game over, nudge, a found match, an invitation or friend
request) arrive as a **Telegram notification** instead — unless the player keeps
notifications in the app only (a profile setting, **on by default**). The "your turn"
@@ -84,8 +85,12 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match
-(always 2 players) joins a per-variant pool and is paired with the next waiting human;
-after 10 s with no human the robot substitutes. For Russian games (auto-match or friend
+(always 2 players) drops you **straight into the game and you wait inside it**: if it is your turn you
+can already move, otherwise you watch your tiles. While no opponent has joined, the opponent card (and
+the game's row in the lobby) reads **"searching for opponent"**, and resign, chat and nudge are
+unavailable. Another player searching the same variant and rule joins your game; failing that, a robot
+takes the empty seat after **1.5–3 minutes**, so a game always starts — and the New Game screen notes
+you can close the app while you wait and come back later. For Russian games (auto-match or friend
invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays
the simplified **single-word rule** — only the word laid along the player's line must be a
real word, and any incidental perpendicular words are ignored and not scored — while on is
@@ -121,8 +126,8 @@ the opponent's turn**, but that draft is position-only — the score preview and
stay available only on the player's own turn.
### Robot opponent
-When auto-match finds no human within ten seconds, a robot opponent takes the empty
-seat so the game starts without waiting. It is meant to feel like a person: it
+When auto-match finds no human within the wait window (1.5–3 minutes), a robot opponent
+takes the empty seat of the game you are already waiting in. It is meant to feel like a person: it
decides once per game whether to play to win (about 40% of the time, so the human
wins most games), aims for a close score rather than crushing or throwing the game,
and plays at a human pace — short thinking times for most moves, the occasional long
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index f201d97..8bda84f 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -42,9 +42,9 @@ nudge) приходят от бота **этой партии** — по язы
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
-чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без
-перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
-события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья)
+чат, nudge и подключение соперника к игре, в которой вы ждёте. Каждое обновление приходит самим событием и применяется на месте без
+перезагрузки — доска обновляется бесшовно, а приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
+события (ваш ход, конец партии, nudge, приглашение или заявка в друзья)
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
уведомления только в приложении (настройка профиля, **включена по умолчанию**).
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
@@ -87,9 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
-любом языке. Авто-подбор (всегда 2 игрока)
-встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
-без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране
+любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо
+в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
+присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
+сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
+игре; если такого нет — через **1,5–3 минуты** свободное место занимает робот, так что игра всегда
+стартует, и экран новой игры подсказывает, что можно закрыть приложение на время ожидания и вернуться
+позже. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
@@ -126,8 +130,8 @@ nudge) приходят от бота **этой партии** — по язы
предпросмотр счёта и отправка доступны лишь в собственный ход.
### Робот-соперник
-Если авто-подбор не находит человека за десять секунд, свободное место занимает
-робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
+Если авто-подбор не находит человека за время ожидания (1,5–3 минуты), свободное место в игре,
+в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека:
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index a4ddd69..fb47aa6 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -193,7 +193,12 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
of starting a game; a lone offered variant is pre-selected, and a bottom **Start game**
button (disabled until a variant is chosen) confirms. For a **Russian** variant (either
flow) a **"Multiple words per turn"** checkbox (`.toggle`, **default off** = the single-word
- rule) appears once that variant is selected; English variants never show it.
+ rule) appears once that variant is selected; English variants never show it. Starting an
+ auto-match **enters the game immediately** and waits inside it: until an opponent joins, the
+ opponent's score card (and the game's lobby row) reads the localized **"searching for opponent"**
+ placeholder, the add-friend 🤝 is hidden, and resign and the chat's send/nudge are disabled; an
+ **opponent_joined** push restores them in place when a human or robot takes the seat, and a line
+ under Start game notes the wait can take a while (the app may be closed meanwhile).
- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat
cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts.
diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go
index eb0e7c4..ae99486 100644
--- a/gateway/internal/transcode/encode.go
+++ b/gateway/internal/transcode/encode.go
@@ -166,14 +166,17 @@ func toWireState(s backendclient.StateResp) wire.StateView {
// encodeMatch builds a MatchResult payload.
func encodeMatch(m backendclient.MatchResp) []byte {
b := flatbuffers.NewBuilder(512)
- matched := m.Matched && m.Game != nil
+ // Enqueue always lands the caller in a game; an open game awaiting an opponent reports
+ // matched=false but still carries it, so encode the game whenever it is present (else the
+ // client never receives it and cannot navigate in).
+ hasGame := m.Game != nil
var game flatbuffers.UOffsetT
- if matched {
+ if hasGame {
game = buildGameView(b, *m.Game)
}
fb.MatchResultStart(b)
- fb.MatchResultAddMatched(b, matched)
- if matched {
+ fb.MatchResultAddMatched(b, m.Matched)
+ if hasGame {
fb.MatchResultAddGame(b, game)
}
b.Finish(fb.MatchResultEnd(b))
diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go
index f66cb48..e96af40 100644
--- a/gateway/internal/transcode/transcode_test.go
+++ b/gateway/internal/transcode/transcode_test.go
@@ -114,6 +114,36 @@ func TestEnqueueRoundTripEncodesMatch(t *testing.T) {
}
}
+func TestEnqueueEncodesOpenGameWhenNotMatched(t *testing.T) {
+ // An auto-match enqueue that opens a game awaiting an opponent returns matched=false but
+ // still carries the game; it must reach the client so it navigates into the game at once.
+ backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"matched":false,"game":{"id":"g-open","variant":"scrabble_en","status":"open","players":2,"to_move":0,"seats":[]}}`))
+ })
+ defer cleanup()
+
+ reg := transcode.NewRegistry(backend, nil)
+ op, _ := reg.Lookup(transcode.MsgLobbyEnqueue)
+
+ b := flatbuffers.NewBuilder(32)
+ v := b.CreateString("scrabble_en")
+ fb.EnqueueRequestStart(b)
+ fb.EnqueueRequestAddVariant(b, v)
+ b.Finish(fb.EnqueueRequestEnd(b))
+
+ payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"})
+ if err != nil {
+ t.Fatalf("handler: %v", err)
+ }
+ m := fb.GetRootAsMatchResult(payload, 0)
+ if m.Matched() {
+ t.Fatal("an open game awaiting an opponent must report matched=false")
+ }
+ if g := m.Game(nil); g == nil || string(g.Id()) != "g-open" {
+ t.Fatalf("open game must be on the wire even when not matched: %+v", g)
+ }
+}
+
func TestDomainErrorSurfacesBackendCode(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts
index f4ce823..7f464e3 100644
--- a/ui/e2e/game.spec.ts
+++ b/ui/e2e/game.spec.ts
@@ -70,19 +70,21 @@ test('new game: variant buttons show a rules summary and the move-limit', async
await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
});
-test('new game: auto-match selects a variant, then a Russian pick reveals the off-by-default toggle', async ({ page }) => {
+test('new game: auto-match shows the off-by-default rule toggle from the start (no layout jump on selection)', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // auto-match
- // Several variants are offered, so nothing is selected: Start is disabled and there is no toggle yet.
+ // Several variants are offered, so nothing is selected: Start is disabled. The rule toggle is shown
+ // from the start (a Russian variant is available), so selecting one does not shift the layout.
const start = page.getByRole('button', { name: /Start game/i });
await expect(start).toBeDisabled();
- await expect(page.getByLabel('Multiple words per turn')).toHaveCount(0);
- // Selecting the Russian Scrabble variant highlights it, enables Start, and reveals the rule toggle (off).
+ const toggle = page.getByLabel('Multiple words per turn');
+ await expect(toggle).toBeVisible();
+ await expect(toggle).not.toBeChecked();
+ // Selecting the Russian Scrabble variant highlights it and enables Start; the toggle stays put.
await page.locator('.variant', { hasText: 'Скрэббл' }).click();
await expect(page.locator('.variant.selected')).toHaveCount(1);
await expect(start).toBeEnabled();
- const toggle = page.getByLabel('Multiple words per turn');
await expect(toggle).toBeVisible();
await expect(toggle).not.toBeChecked();
});
diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts
new file mode 100644
index 0000000..903649f
--- /dev/null
+++ b/ui/e2e/quickmatch.spec.ts
@@ -0,0 +1,31 @@
+import { expect, test } from './fixtures';
+
+// The quick-match flow drops the player straight into a game that is still waiting for an
+// opponent (status 'open'): the opponent card shows "searching for opponent" and resign is
+// disabled until the mock attaches a robot shortly after, which restores the game UI. Driven
+// entirely by the mock transport (no backend).
+
+test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: /guest/i }).click();
+ await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
+
+ // Pick a variant and start; the player lands in the game at once (no "searching" screen).
+ await page.locator('.variant').first().click();
+ await page.getByRole('button', { name: /Start game/i }).click();
+ await expect(page.locator('[data-cell]').first()).toBeVisible();
+
+ // Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the
+ // history panel) is disabled.
+ await expect(page.getByText(/Searching for opponent/)).toBeVisible();
+ await page.locator('.scoreboard').click(); // open the history panel
+ await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled();
+
+ // Attach the opponent deterministically (the mock otherwise joins on a timer).
+ await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
+
+ // The opponent card shows its name, the placeholder is gone, and resign is enabled again.
+ await expect(page.getByText('Robo')).toBeVisible();
+ await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
+});
diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte
index f148140..6e689e6 100644
--- a/ui/src/game/Chat.svelte
+++ b/ui/src/game/Chat.svelte
@@ -8,6 +8,7 @@
myId,
busy,
myTurn = false,
+ waiting = false,
nudgeOnCooldown = false,
onsend,
onnudge,
@@ -20,6 +21,9 @@
// hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
// cooldown is active the nudge is disabled with an "awaiting reply" caption.
myTurn?: boolean;
+ // waiting is true while an auto-match game still has no opponent: both send and nudge
+ // are disabled (there is no one to message or hurry yet).
+ waiting?: boolean;
nudgeOnCooldown?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
@@ -56,11 +60,11 @@
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()}
/>
-
+
{:else}
{nudgeOnCooldown ? t('chat.awaitingReply') : ''}
-
+
{/if}
diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte
index 5f88566..5f289a4 100644
--- a/ui/src/game/ChatScreen.svelte
+++ b/ui/src/game/ChatScreen.svelte
@@ -17,7 +17,11 @@
let tick = $state(0);
const myId = $derived(app.session?.userId ?? '');
- const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
+ const isMyTurn = $derived(
+ !!view && (view.game.status === 'active' || view.game.status === 'open') && view.game.toMove === view.seat,
+ );
+ // While the auto-match game still has no opponent, chat and nudge are both disabled.
+ const waiting = $derived(!!view && view.game.status === 'open');
const nudgeCooldownSecs = 3600;
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
// backend stays authoritative, so a move-based reset is left to it.
@@ -87,4 +91,4 @@
}
-