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

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

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

Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
This commit is contained in:
Ilia Denisov
2026-06-12 16:00:22 +02:00
parent 10dc1f0d48
commit c305363ccd
42 changed files with 1248 additions and 768 deletions
+9
View File
@@ -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 90180 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 090 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
+11 -9
View File
@@ -28,9 +28,10 @@ export, per-account statistics on finish, and a background turn-timeout sweeper
that auto-resigns overdue turns (honouring each player's daily away window). Like
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
The lobby and social fabric. `internal/lobby` holds an in-memory
matchmaking pool (FIFO per variant and per-turn word rule, pairs two humans into an
auto-match) and
The lobby and social fabric. `internal/lobby` runs **auto-match**`Enqueue` opens a
real game seating the caller with an **empty opponent seat** (status `open`) or, when
another player already waits for the same variant and per-turn word rule, seats the
caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
@@ -52,16 +53,17 @@ robot's moves through the public game API as an ordinary seated player (so only
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
initial game state) drives it instead.
state. A background **reaper** seats a pooled robot (matching the game's language) in any open
game whose wait window — a fixed **90 s** plus a random **090 s** (so **90180 s**) — has
elapsed, and the waiting starter is told an opponent took the seat by an in-app
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
re-enables resign and chat in place.
The backend opens to the edge. The route groups gain their first
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
slice of authenticated `/api/v1/user` operations (profile, submit play, game
state, lobby enqueue/poll, chat). The social/account/history operations under
state, lobby enqueue, chat). The social/account/history operations under
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
@@ -126,7 +128,7 @@ internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
+4 -2
View File
@@ -170,12 +170,14 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
go robots.Run(ctx, cfg.Robot.DriveInterval)
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger)
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
matchmaker.SetNotifier(hub)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
invitations.SetNotifier(hub)
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
logger.Info("lobby and social domains ready",
zap.Duration("robot_wait", cfg.Lobby.RobotWait),
zap.Duration("robot_wait_jitter", cfg.Lobby.RobotWaitJitter))
// Rate-limit observability: ingest the gateway's rejection reports for the
// admin throttled view and the conservative high-rate auto-flag.
+3
View File
@@ -100,6 +100,9 @@ func Load() (Config, error) {
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
return Config{}, err
}
if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil {
return Config{}, err
}
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
return Config{}, err
}
+9 -1
View File
@@ -1,6 +1,8 @@
package game
import (
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
)
@@ -21,9 +23,15 @@ func gameSummary(g Game, names []string) notify.GameSummary {
if s.Seat >= 0 && s.Seat < len(names) {
name = names[s.Seat]
}
// An open game's still-empty opponent seat carries no account: send an empty id
// (not the nil-UUID string) so the client renders it as "searching for opponent".
accountID := ""
if s.AccountID != uuid.Nil {
accountID = s.AccountID.String()
}
seats = append(seats, notify.SeatStanding{
Seat: s.Seat,
AccountID: s.AccountID.String(),
AccountID: accountID,
DisplayName: name,
Score: s.Score,
HintsUsed: s.HintsUsed,
+108 -4
View File
@@ -145,6 +145,96 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
return svc.store.GetGame(ctx, id)
}
// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
// params and returns the game they land in immediately: another waiting player's open
// game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open
// game seating only the caller with an empty opponent seat that a human or the reaper's
// robot fills later. openDeadline is when the reaper substitutes a robot into a freshly
// opened game (ignored when joining one). The bag seed defaults to random; params.Seed
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
if _, err := svc.accounts.GetByID(ctx, accountID); err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID)
}
return Game{}, false, err
}
timeout := params.TurnTimeout
if timeout == 0 {
timeout = DefaultTurnTimeout
}
if !allowedTimeout(timeout) {
return Game{}, false, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
}
id, err := uuid.NewV7()
if err != nil {
return Game{}, false, fmt.Errorf("game: new id: %w", err)
}
seed := params.Seed
if seed == 0 {
seed = svc.rng()
}
deadline := openDeadline
ins := gameInsert{
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
seed: seed,
players: 2,
turnTimeoutSecs: int(timeout / time.Second),
hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
status: StatusOpen,
openDeadline: &deadline,
}
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat
// is left empty (uuid.Nil) for the opponent.
seats := []uuid.UUID{accountID, uuid.Nil}
if seed&1 == 1 {
seats = []uuid.UUID{uuid.Nil, accountID}
}
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats)
if err != nil {
return Game{}, false, err
}
if created {
svc.metrics.recordStarted(ctx, params.Variant)
}
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Game{}, false, err
}
return g, joined, nil
}
// AttachRobot seats robotID in the empty opponent seat of open game gameID and flips
// it to active, returning the now-active game and whether it attached (false, with a
// zero Game, when a human joined first). It backs the matchmaking reaper.
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (Game, bool, error) {
attached, err := svc.store.AttachRobot(ctx, gameID, robotID)
if err != nil {
return Game{}, false, err
}
if !attached {
return Game{}, false, nil
}
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Game{}, false, err
}
return g, true, nil
}
// ExpiredOpen returns the open games due for a robot substitution (deadline at or
// before now) for the matchmaking reaper.
func (svc *Service) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
return svc.store.ExpiredOpen(ctx, now)
}
// engineOp applies one transition to the live game, returning the decoded record
// and, for an exchange, the swapped tiles.
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
@@ -189,6 +279,11 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
if !ok {
return MoveResult{}, ErrNotAPlayer
}
// Resign needs a present opponent to award the win, so it is refused while the game
// is still waiting for one; the UI keeps the button disabled until then.
if pre.Status == StatusOpen {
return MoveResult{}, ErrNoOpponentYet
}
if pre.Status != StatusActive {
return MoveResult{}, ErrFinished
}
@@ -260,7 +355,10 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if !ok {
return MoveResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
// A move is allowed while the game is active or still open (the starter may move on
// their turn before an opponent joins); only a finished game rejects it. The turn
// check below keeps the starter off the still-empty opponent seat.
if pre.Status == StatusFinished {
return MoveResult{}, ErrFinished
}
if pre.ToMove != seat {
@@ -382,6 +480,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
summary := gameSummary(post, names)
intents := make([]notify.Intent, 0, 2*len(post.Seats))
for _, s := range post.Seats {
if s.AccountID == uuid.Nil {
continue // an open game's opponent seat is not yet filled — nobody to notify
}
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
}
// Game pushes are routed out-of-app by the game's own language, not the recipient's
@@ -406,6 +507,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
// seat, each with their own perspective + recipient-first score, so an offline player gets
// an out-of-app "game over" push (online players take it from the in-app refresh).
for _, s := range post.Seats {
if s.AccountID == uuid.Nil {
continue
}
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
over.Language = lang
intents = append(intents, over)
@@ -540,7 +644,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
if _, ok := pre.seatOf(accountID); !ok {
return EvalResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
if pre.Status == StatusFinished {
return EvalResult{}, ErrFinished
}
@@ -674,7 +778,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
if !ok {
return HintResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
if pre.Status == StatusFinished {
return HintResult{}, ErrFinished
}
if pre.ToMove != seat {
@@ -736,7 +840,7 @@ func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID)
if !ok {
return nil, ErrNotAPlayer
}
if pre.Status != StatusActive {
if pre.Status == StatusFinished {
return nil, ErrFinished
}
if pre.ToMove != seat {
+186 -5
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"hash/fnv"
"time"
"github.com/go-jet/jet/v2/postgres"
@@ -40,6 +41,13 @@ type gameInsert struct {
dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn bool
// status is the lifecycle state to create the game in: StatusActive for a normal
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
// empty string defaults to StatusActive.
status string
// openDeadline, set only for a StatusOpen game, is when the matchmaking reaper
// substitutes a robot if no human has joined; nil for a normal game.
openDeadline *time.Time
}
// statDelta is one account's contribution to its statistics on a game finish.
@@ -91,24 +99,186 @@ type activeGame struct {
// first) inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
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.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
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, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.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
}
pi := table.GamePlayers.INSERT(
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
).VALUES(ins.id, seat, accountID)
).VALUES(ins.id, seat, acc)
if _, err := pi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert seat %d: %w", seat, err)
}
}
return nil
}
// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the
// advisory-lock key that serialises concurrent enqueues for that bucket, so two
// players never both open a game instead of pairing.
func openMatchKey(variant string, multipleWords bool) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte(variant))
if multipleWords {
_, _ = h.Write([]byte{1})
} else {
_, _ = h.Write([]byte{0})
}
return int64(h.Sum64())
}
// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it
// lands in: it re-uses the caller's own still-open game (joined=false, created=false,
// a re-enqueue is idempotent), joins another player's waiting open game and flips it
// active (joined=true), or opens a fresh game seating the caller with an empty
// opponent seat (created=true). ins supplies the new game's immutable fields and is
// used only when a game is created. A transaction-scoped advisory lock on the
// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
// for the still-empty opponent, in the chosen order) used only when a game is created.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameInsert, seats []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
return fmt.Errorf("open match lock: %w", e)
}
// 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent.
var own uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3
LIMIT 1`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); {
case e == nil:
gameID = own
return nil
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find own open game: %w", e)
}
// 2. Another player's open game waiting for an opponent — fill its seat and start it.
var other uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
WHERE p.game_id = g.game_id AND p.account_id = $3)
ORDER BY g.created_at
LIMIT 1 FOR UPDATE SKIP LOCKED`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
case e == nil:
if er := fillOpenSeat(ctx, tx, other, accountID); er != nil {
return er
}
gameID, joined = other, true
return nil
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find open game: %w", e)
}
// 3. None waiting — open a fresh game seating the caller (the other seat empty).
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
return e
}
gameID, created = ins.id, true
return nil
})
return gameID, joined, created, err
}
// AttachRobot fills the empty opponent seat of open game gameID with robotID and
// flips it to active, returning whether it attached. It is a no-op (false) when the
// game is no longer open — a human joined first — so the reaper never double-fills.
func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (bool, error) {
attached := false
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
var status string
switch e := tx.QueryRowContext(ctx,
`SELECT status FROM backend.games WHERE game_id = $1 FOR UPDATE`, gameID).Scan(&status); {
case errors.Is(e, sql.ErrNoRows):
return nil
case e != nil:
return fmt.Errorf("lock game for robot: %w", e)
}
if status != StatusOpen {
return nil
}
if e := fillOpenSeat(ctx, tx, gameID, robotID); e != nil {
return e
}
attached = true
return nil
})
return attached, err
}
// fillOpenSeat seats accountID in an open game's empty opponent seat and flips the
// game to active, stamping a fresh turn clock. The caller holds the game row.
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID) error {
if _, err := tx.ExecContext(ctx,
`UPDATE backend.game_players SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
gameID, accountID); err != nil {
return fmt.Errorf("fill opponent seat: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
WHERE game_id = $1`, gameID); err != nil {
return fmt.Errorf("activate game: %w", err)
}
return nil
}
// ExpiredOpen returns the open games whose robot deadline has passed (at or before
// now), oldest deadline first, for the matchmaking reaper to fill with a robot.
func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT game_id, variant FROM backend.games
WHERE status = 'open' AND open_deadline_at IS NOT NULL AND open_deadline_at <= $1
ORDER BY open_deadline_at`, now)
if err != nil {
return nil, fmt.Errorf("game: expired open: %w", err)
}
defer rows.Close()
var out []OpenGame
for rows.Next() {
var id uuid.UUID
var variantStr string
if err := rows.Scan(&id, &variantStr); err != nil {
return nil, fmt.Errorf("game: scan expired open: %w", err)
}
variant, err := engine.ParseVariant(variantStr)
if err != nil {
return nil, fmt.Errorf("game: expired open %s: %w", id, err)
}
out = append(out, OpenGame{ID: id, Variant: variant})
}
return out, rows.Err()
}
// GetGame loads the games row joined with its seats (ordered by seat), or
@@ -670,9 +840,14 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
}
out := make([]RobotTurn, 0, len(rows))
for _, r := range rows {
// The filter matches only the robot's (non-null) seat, so AccountID is set.
robotID := uuid.Nil
if r.GamePlayers.AccountID != nil {
robotID = *r.GamePlayers.AccountID
}
out = append(out, RobotTurn{
GameID: r.Games.GameID,
RobotID: r.GamePlayers.AccountID,
RobotID: robotID,
RobotSeat: int(r.GamePlayers.Seat),
ToMove: int(r.Games.ToMove),
TurnStartedAt: r.Games.TurnStartedAt,
@@ -773,9 +948,15 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
}
out.Seats = make([]Seat, 0, len(seats))
for _, p := range seats {
// A NULL account_id is the still-empty opponent seat of an open game; surface it
// as uuid.Nil so callers keep the "seats == players" invariant.
accountID := uuid.Nil
if p.AccountID != nil {
accountID = *p.AccountID
}
out.Seats = append(out.Seats, Seat{
Seat: int(p.Seat),
AccountID: p.AccountID,
AccountID: accountID,
Score: int(p.Score),
HintsUsed: int(p.HintsUsed),
IsWinner: p.IsWinner,
+18 -1
View File
@@ -13,11 +13,16 @@ import (
const (
StatusActive = "active"
StatusFinished = "finished"
// StatusOpen is an auto-match game whose starter has already entered it but
// which is still waiting for an opponent: the opponent seat holds no account
// yet. The starter may move on their turn; it becomes StatusActive when a human
// or a robot joins (see OpenGame, Service.OpenOrJoin and Service.AttachRobot).
StatusOpen = "open"
)
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen
// and closed StatusComplaintResolved by the admin review queue with a
// Disposition. The CHECK constraints live in migration 00008.
// Disposition. The CHECK constraints live in the baseline migration.
const (
StatusComplaintOpen = "open"
StatusComplaintResolved = "resolved"
@@ -43,6 +48,10 @@ var (
ErrNotYourTurn = errors.New("game: not the player's turn")
// ErrFinished is returned when a transition is attempted on a finished game.
ErrFinished = errors.New("game: game is finished")
// ErrNoOpponentYet is returned when an action that needs a present opponent
// (resign, chat, nudge) is attempted on an auto-match game still waiting for one
// (StatusOpen).
ErrNoOpponentYet = errors.New("game: no opponent has joined yet")
// ErrGameActive is returned when an operation allowed only on a finished game
// (such as a GCG export) is attempted while the game is still active.
ErrGameActive = errors.New("game: game is still active")
@@ -117,6 +126,14 @@ type Seat struct {
IsWinner bool
}
// OpenGame identifies an auto-match game waiting for an opponent whose robot
// deadline has passed: the game the matchmaking reaper fills and the variant that
// selects the substitute robot.
type OpenGame struct {
ID uuid.UUID
Variant engine.Variant
}
// seatOf returns the seat index of accountID and true, or (0, false) when the
// account is not seated.
func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
+14 -4
View File
@@ -56,11 +56,21 @@ func newRobotService(t *testing.T, games *game.Service) *robot.Service {
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
}
// newMatchmaker builds a matchmaker starting real games and substituting from
// robots after wait.
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
// newMatchmaker builds a matchmaker opening real games and substituting from robots
// after minWait plus a random jitter in [0, jitter).
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, minWait, jitter time.Duration) *lobby.Matchmaker {
t.Helper()
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
return lobby.NewMatchmaker(newGameService(), robots, minWait, jitter, zap.NewNop())
}
// clearOpenGames deletes every open (awaiting-opponent) game so a matchmaking test
// starts from a clean slate: the shared test database is FIFO-joined across tests, so a
// leftover open game would otherwise be joined (or opened-into) instead of a fresh one.
func clearOpenGames(t *testing.T) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(), `DELETE FROM backend.games WHERE status = 'open'`); err != nil {
t.Fatalf("clear open games: %v", err)
}
}
// provisionAccount creates a fresh durable account and returns its id.
+43 -7
View File
@@ -30,31 +30,67 @@ func englishInvite() lobby.InvitationSettings {
}
}
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
func TestMatchmakingOpensThenJoins(t *testing.T) {
ctx := context.Background()
mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second)
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
a, b := provisionAccount(t), provisionAccount(t)
// The first player opens a game and enters it immediately, still awaiting an opponent.
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must wait")
t.Fatal("first enqueue must open a game awaiting an opponent, not match")
}
if _, _, status, err := newGameService().Participants(ctx, r1.Game.ID); err != nil || status != "open" {
t.Fatalf("opened game status = %q err %v, want open", status, err)
}
// A second player for the same variant and rule joins that open game, which starts.
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if !r2.Matched {
t.Fatal("second enqueue must match")
if !r2.Matched || r2.Game.ID != r1.Game.ID {
t.Fatalf("second enqueue = (matched %v, game %s), want it to join the open game %s", r2.Matched, r2.Game.ID, r1.Game.ID)
}
seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != "active" || len(seats) != 2 {
t.Fatalf("matched game state: status %q seats %v", status, seats)
has := func(id uuid.UUID) bool {
for _, s := range seats {
if s == id {
return true
}
}
return false
}
if status != "active" || len(seats) != 2 || !has(a) || !has(b) {
t.Fatalf("joined game: status %q seats %v (want active with a=%s and b=%s)", status, seats, a, b)
}
}
// TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the
// caller gets their existing open game rather than a second one.
func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
a := provisionAccount(t)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("re-enqueue: %v", err)
}
if r2.Game.ID != r1.Game.ID || r2.Matched {
t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID)
}
}
+254
View File
@@ -0,0 +1,254 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social"
)
// The open-game suite covers an auto-match game that a player enters immediately and
// waits inside (status 'open', the opponent seat empty) until a human or a robot joins.
// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
// moves first) whose fresh two-player English opening rack has a legal move.
func evenOpeningSeed(t *testing.T) int64 {
t.Helper()
for seed := int64(2); seed <= 400; seed += 2 {
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
if err != nil {
t.Fatalf("engine new: %v", err)
}
if _, ok := g.HintView(); ok {
return seed
}
}
t.Fatal("no even opening seed found")
return 0
}
// openParams are the casual auto-match settings with a pinned seed.
func openParams(seed int64) game.CreateParams {
return game.CreateParams{
Variant: engine.VariantEnglish,
TurnTimeout: 24 * time.Hour,
HintsAllowed: true,
HintsPerPlayer: 1,
MultipleWordsPerTurn: true,
Seed: seed,
}
}
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
t.Helper()
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("open game: %v", err)
}
if joined || g.Status != game.StatusOpen {
t.Fatalf("opened game = (joined %v, status %q), want (false, open)", joined, g.Status)
}
return g
}
// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
// while the game is open, after which it is the empty opponent seat's turn and the
// starter just waits.
func TestOpenGameStarterMovesThenWaits(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
seed := evenOpeningSeed(t)
g := openGame(t, svc, provisionAccount(t), seed)
starter := g.Seats[0].AccountID
hint, ok := newMirror(t, seed, 2).HintView()
if !ok || len(hint.Tiles) == 0 {
t.Fatal("no opening move for the seed")
}
res, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles)
if err != nil {
t.Fatalf("starter play while open: %v", err)
}
if res.Game.Status != game.StatusOpen {
t.Errorf("after the starter's move the game must stay open, got %q", res.Game.Status)
}
if res.Game.ToMove != 1 {
t.Errorf("after the starter's move it must be the empty seat's turn (to_move 1), got %d", res.Game.ToMove)
}
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
t.Fatalf("starter acting on the opponent's turn = %v, want ErrNotYourTurn", err)
}
}
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("open: %v", err)
}
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
}
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err)
}
}
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after
// the starter has already made their first move, landing on the board mid-opening and
// able to act on their turn.
func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
seed := evenOpeningSeed(t)
g := openGame(t, svc, provisionAccount(t), seed)
starter := g.Seats[0].AccountID
hint, ok := newMirror(t, seed, 2).HintView()
if !ok {
t.Fatal("no opening move")
}
if _, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles); err != nil {
t.Fatalf("starter play: %v", err)
}
joiner := provisionAccount(t)
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("join: %v", err)
}
if !joined || g2.ID != g.ID {
t.Fatalf("join = (joined %v, game %s), want it to join %s", joined, g2.ID, g.ID)
}
if g2.Status != game.StatusActive || g2.MoveCount != 1 || g2.ToMove != 1 {
t.Fatalf("joined game = (status %q, moves %d, to_move %d), want (active, 1, 1)", g2.Status, g2.MoveCount, g2.ToMove)
}
// It is the joiner's turn (seat 1); they can act.
if _, err := svc.Pass(ctx, g.ID, joiner); err != nil {
t.Fatalf("joiner pass on their turn: %v", err)
}
}
// TestOpenGameResignRejectedUntilOpponent checks resign is refused while the game is
// open and allowed once an opponent (a robot here) has joined.
func TestOpenGameResignRejectedUntilOpponent(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
starter := g.Seats[0].AccountID
if _, err := svc.Resign(ctx, g.ID, starter); !errors.Is(err, game.ErrNoOpponentYet) {
t.Fatalf("resign while open = %v, want ErrNoOpponentYet", err)
}
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
if _, attached, err := svc.AttachRobot(ctx, g.ID, robotID); err != nil || !attached {
t.Fatalf("attach robot = (attached %v, err %v), want attached", attached, err)
}
res, err := svc.Resign(ctx, g.ID, starter)
if err != nil {
t.Fatalf("resign after the opponent joined: %v", err)
}
if res.Game.Status != game.StatusFinished {
t.Errorf("resign must finish the game, got %q", res.Game.Status)
}
}
// TestOpenGameChatAndNudgeRejected checks chat and nudge are refused while the game is
// open (no opponent to converse with).
func TestOpenGameChatAndNudgeRejected(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
soc := newSocialService()
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
starter := g.Seats[0].AccountID
if _, err := soc.PostMessage(ctx, g.ID, starter, "hello", ""); !errors.Is(err, social.ErrGameNotActive) {
t.Errorf("chat while open = %v, want ErrGameNotActive", err)
}
if _, err := soc.Nudge(ctx, g.ID, starter); !errors.Is(err, social.ErrGameNotActive) {
t.Errorf("nudge while open = %v, want ErrGameNotActive", err)
}
}
// TestOpenGameSweeperSkips checks the turn-timeout sweeper never finishes an open game,
// even with a long-stale turn clock (its seat is the empty opponent's).
func TestOpenGameSweeperSkips(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
setTurnStarted(t, g.ID, time.Now().Add(-1000*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
g2, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if g2.Status != game.StatusOpen {
t.Errorf("open game must survive the sweeper, got %q", g2.Status)
}
}
// TestOpenGameLobbyShowsEmptySeat checks an open game appears in the starter's lobby
// with two seats, one of them the still-empty opponent (uuid.Nil).
func TestOpenGameLobbyShowsEmptySeat(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g := openGame(t, svc, starter, evenOpeningSeed(t))
games, err := svc.ListForAccount(ctx, starter)
if err != nil {
t.Fatalf("list for account: %v", err)
}
var found *game.Game
for i := range games {
if games[i].ID == g.ID {
found = &games[i]
break
}
}
if found == nil {
t.Fatal("open game must appear in the starter's lobby")
}
var hasStarter, hasEmpty bool
for _, s := range found.Seats {
switch s.AccountID {
case starter:
hasStarter = true
case uuid.Nil:
hasEmpty = true
}
}
if len(found.Seats) != 2 || !hasStarter || !hasEmpty {
t.Errorf("open game seats = %+v, want the starter and one empty (nil) seat", found.Seats)
}
}
+8 -15
View File
@@ -138,36 +138,29 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
}
}
// TestMatchmakerSubstitutesRobotEndToEnd checks a waiting human is paired with a
// real robot account after the wait window, discoverable through Poll.
// TestMatchmakerSubstitutesRobotEndToEnd checks the reaper fills an open game's empty
// seat with a real robot account once its wait window has elapsed.
func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
robots := newRobotService(t, newGameService())
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
mm := newMatchmaker(t, robots, 10*time.Second)
// Zero wait and jitter so the opened game is immediately due for a robot.
mm := newMatchmaker(t, robots, 0, 0)
human := provisionAccount(t)
before := time.Now()
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
if r.Matched {
t.Fatal("first enqueue must wait")
t.Fatal("first enqueue must open a game awaiting an opponent")
}
mm.Reap(ctx, before.Add(11*time.Second))
got, err := mm.Poll(ctx, human)
if err != nil {
t.Fatalf("poll: %v", err)
}
if !got.Matched {
t.Fatal("expected a substituted game after the wait window")
}
seats, _, status, err := newGameService().Participants(ctx, got.Game.ID)
mm.Reap(ctx, time.Now().Add(time.Second)) // past the (zero) wait window
seats, _, status, err := newGameService().Participants(ctx, r.Game.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
+20 -9
View File
@@ -5,22 +5,30 @@ import (
"time"
)
// Config configures the matchmaking pool's robot substitution.
// Config configures auto-match robot substitution: how long an open game waits for a
// human opponent before a robot is substituted, and how often the reaper scans.
type Config struct {
// RobotWait is how long an auto-match player waits for a human before a robot
// is substituted. Sourced from BACKEND_LOBBY_ROBOT_WAIT.
// RobotWait is the fixed minimum an open auto-match game waits for a human
// opponent before it is eligible for robot substitution. Sourced from
// BACKEND_LOBBY_ROBOT_WAIT.
RobotWait time.Duration
// ReaperInterval is how often the substitution reaper scans for over-waited
// players. Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
// RobotWaitJitter is a random extra wait in [0, RobotWaitJitter) added on top of
// RobotWait per game, so the substitution time varies. Sourced from
// BACKEND_LOBBY_ROBOT_WAIT_JITTER.
RobotWaitJitter time.Duration
// ReaperInterval is how often the reaper scans for open games due for a robot.
// Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
ReaperInterval time.Duration
}
// DefaultConfig returns the matchmaking defaults: a 10-second wait
// (docs/ARCHITECTURE.md §7) scanned every second.
// DefaultConfig returns the matchmaking defaults: a guaranteed 90-second wait for a
// human plus up to 90 random seconds (90180 s total) before a robot substitutes
// (docs/ARCHITECTURE.md §7), scanned every five seconds.
func DefaultConfig() Config {
return Config{
RobotWait: 10 * time.Second,
ReaperInterval: time.Second,
RobotWait: 90 * time.Second,
RobotWaitJitter: 90 * time.Second,
ReaperInterval: 5 * time.Second,
}
}
@@ -29,6 +37,9 @@ func (c Config) Validate() error {
if c.RobotWait <= 0 {
return fmt.Errorf("lobby: robot wait must be positive, got %s", c.RobotWait)
}
if c.RobotWaitJitter < 0 {
return fmt.Errorf("lobby: robot wait jitter must not be negative, got %s", c.RobotWaitJitter)
}
if c.ReaperInterval <= 0 {
return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval)
}
+9 -10
View File
@@ -1,9 +1,10 @@
// Package lobby forms games: an in-memory matchmaking pool that pairs two humans
// for an auto-match, and friend-game invitations (invite -> accept) that start a
// 2-4 player game once every invitee has accepted. Both produce a game through the
// game domain (a GameCreator); neither imports the engine. The matchmaking pool
// is in-memory and lost on restart (players re-queue); the robot that substitutes
// for a missing human after a short wait is added in a later stage.
// Package lobby forms games: an auto-match maker that drops a player straight into a
// game with an empty opponent seat (or joins them into another player's waiting one),
// and friend-game invitations (invite -> accept) that start a 2-4 player game once
// every invitee has accepted. Both produce games through the game domain; neither
// imports the engine. Auto-match state is the open games in the database, so it
// survives a restart; a background reaper substitutes a pooled robot for any open game
// that waits too long, guaranteeing every game gets an opponent.
package lobby
import (
@@ -22,8 +23,8 @@ import (
type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
// InitialState returns a seated player's full initial view of a started game, used
// to enrich the match_found / game_started events so the client renders the new game
// without a follow-up fetch.
// to enrich the game_started event so the client renders the new game without a
// follow-up fetch.
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
}
@@ -51,8 +52,6 @@ const (
// Sentinel errors returned by the lobby.
var (
// ErrAlreadyQueued is returned when an account already waits in a pool.
ErrAlreadyQueued = errors.New("lobby: account already in the matchmaking pool")
// ErrInvalidInvitation is returned for a malformed invitation (bad player
// count, duplicate or self invitee, or unacceptable settings).
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
+105 -191
View File
@@ -2,8 +2,7 @@ package lobby
import (
"context"
"math/rand"
"sync"
"math/rand/v2"
"time"
"github.com/google/uuid"
@@ -14,182 +13,91 @@ import (
"scrabble/backend/internal/notify"
)
// matchKey buckets the auto-match pool: two players are paired only when they chose
// the same variant and the same per-turn word rule (multipleWords), so a game always
// starts under a rule both players asked for.
type matchKey struct {
variant engine.Variant
multipleWords bool
// GameMatcher is the slice of the game domain the matchmaker drives: opening or
// joining an auto-match game, substituting a robot into one whose wait elapsed, and
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
// it.
type GameMatcher interface {
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error)
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
}
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
// the next two humans into a two-player game, or — when no human arrives within
// the wait window — substitutes a robot. It holds no database state and is lost on
// restart (players simply re-queue). It is safe for concurrent use.
// Matchmaker turns an auto-match enqueue into a real game the player enters at once:
// it opens a game with an empty opponent seat, or joins the caller into another
// player's waiting one. A background reaper substitutes a pooled robot for any open
// game whose wait window has elapsed, guaranteeing every game gets an opponent. All
// matchmaking state is the open games in the database, so it survives a restart; the
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
// concurrent use.
//
// Auto-match is anonymous, so the pool does not consult per-user blocks (those
// govern friends, chat and invitations between known players).
//
// A player who is queued learns of a match — by a waiting human being paired, or
// by robot substitution — through Poll, the interim delivery seam: production
// delivery is a notification (session/in-app push and the platform side-service,
// docs/ARCHITECTURE.md §10), wired with the gateway in a later stage.
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
// friends, chat and invitations between known players).
type Matchmaker struct {
games GameCreator
games GameMatcher
robots RobotProvider
waitDelay time.Duration
minWait time.Duration
jitter 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
}
// 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,
minWait: minWait,
jitter: jitter,
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())),
}
}
// 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
if joined {
m.announceOpponent(ctx, g, accountID)
}
return EnqueueResult{Matched: joined, 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
}
return EnqueueResult{}, 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)
}
// autoMatchParams builds the create parameters for a two-player auto-match with
// the casual defaults.
func autoMatchParams(key matchKey, seats []uuid.UUID) game.CreateParams {
// 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; 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,
}
}
+136 -270
View File
@@ -14,28 +14,51 @@ import (
"scrabble/backend/internal/notify"
)
// fakeCreator records the games a matchmaker asks it to start.
type fakeCreator struct {
created []game.CreateParams
err error
// stubMatcher is a fake GameMatcher: it returns canned games and records the calls the
// matchmaker makes, so the unit tests cover delegation, the opponent_joined emit and
// the wait-window math without a database. The DB-backed open/join/substitute logic is
// covered by the integration suite.
type stubMatcher struct {
openGame game.Game
openJoined bool
openErr error
openCalls int
lastDeadline time.Time
expired []game.OpenGame
attachGame game.Game
attached bool
attachErr error
attachedGames []uuid.UUID
}
func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) {
if f.err != nil {
return game.Game{}, f.err
}
f.created = append(f.created, p)
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
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
}
// 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) AttachRobot(_ context.Context, gameID, _ uuid.UUID) (game.Game, bool, error) {
if s.attachErr != nil {
return game.Game{}, false, s.attachErr
}
if s.attached {
s.attachedGames = append(s.attachedGames, gameID)
}
return s.attachGame, s.attached, nil
}
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...) }
// 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},
},
}
}
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
}
}
return true
}
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)
func TestMatchmakerPairsTwoHumans(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
a, b := uuid.New(), uuid.New()
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 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))
}
if mm.QueueLen(engine.VariantEnglish) != 1 {
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
}
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))
}
}
mm.Reap(context.Background(), time.Now())
// 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")
if len(pub.intents) != 0 {
t.Errorf("a human-filled game must not emit opponent_joined; got %d", len(pub.intents))
}
}
+16
View File
@@ -122,6 +122,22 @@ func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()}
}
// OpponentJoined tells userID — the starter of an auto-match game still shown as
// "searching for an opponent" — that an opponent (a human or a substituted robot) has
// taken the empty seat. state is the starter's refreshed view (now seating both
// players), so the client fills the opponent card and re-enables resign and chat in
// place without navigating. It reuses the match_found payload layout (game id + state).
func OpponentJoined(userID, gameID uuid.UUID, state PlayerState) Intent {
b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String())
stateOff := buildStateView(b, state)
fb.MatchFoundEventStart(b)
fb.MatchFoundEventAddGameId(b, gid)
fb.MatchFoundEventAddState(b, stateOff)
b.Finish(fb.MatchFoundEventEnd(b))
return Intent{UserID: userID, Kind: KindOpponentJoined, Payload: b.FinishedBytes(), EventID: eventID()}
}
// Notification is a lightweight "re-poll" signal to userID that something in their lobby
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
+5
View File
@@ -24,6 +24,11 @@ const (
KindChatMessage = "chat_message"
KindNudge = "nudge"
KindMatchFound = "match_found"
// KindOpponentJoined tells the starter of an auto-match game still "searching for an
// opponent" that the empty seat has been taken (by a human or a substituted robot),
// carrying the refreshed StateView so the client fills the opponent card and
// re-enables resign and chat in place. In-app only (never an out-of-app push).
KindOpponentJoined = "opponent_joined"
// KindNotification is a lightweight "re-poll your lobby counters" signal
// (incoming friend requests, invitations) that drives the lobby badge.
KindNotification = "notify"
@@ -14,7 +14,7 @@ import (
type GamePlayers struct {
GameID uuid.UUID `sql:"primary_key"`
Seat int16 `sql:"primary_key"`
AccountID uuid.UUID
AccountID *uuid.UUID
Score int32
HintsUsed int16
IsWinner bool
@@ -29,6 +29,7 @@ type Games struct {
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
OpenDeadlineAt *time.Time
DropoutTiles string
MultipleWordsPerTurn bool
}
@@ -33,6 +33,7 @@ type gamesTable struct {
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
OpenDeadlineAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
@@ -92,10 +93,11 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
OpenDeadlineAtColumn = postgres.TimestampzColumn("open_deadline_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
)
@@ -119,6 +121,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
OpenDeadlineAt: OpenDeadlineAtColumn,
DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
@@ -96,10 +96,14 @@ CREATE TABLE games (
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
-- open_deadline_at is set only while status='open' (an auto-match game awaiting an
-- opponent): the instant the matchmaking reaper substitutes a robot if no human has
-- joined by then. NULL for every active and finished game.
open_deadline_at timestamptz,
dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true,
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished', 'open')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
@@ -112,15 +116,19 @@ CREATE TABLE games (
-- The sweeper scans active games oldest-turn-first; a partial index keeps it off the
-- finished archive.
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
-- The matchmaking reaper scans open games due for a robot substitution; a partial index
-- keeps it off the active and finished games.
CREATE INDEX games_open_idx ON games (open_deadline_at) WHERE status = 'open';
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
-- durable account. score is the running/final score, is_winner is stamped on finish
-- (false for every seat on a draw), hints_used counts the per-game allowance consumed
-- before the profile wallet.
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a durable
-- account, or NULL for the still-empty opponent seat of an auto-match game waiting for an
-- opponent (status='open'); it is filled when a human or a robot joins. score is the
-- running/final score, is_winner is stamped on finish (false for every seat on a draw),
-- hints_used counts the per-game allowance consumed before the profile wallet.
CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (account_id),
account_id uuid REFERENCES accounts (account_id),
score integer NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0,
is_winner boolean NOT NULL DEFAULT false,
+14 -6
View File
@@ -1,6 +1,8 @@
package server
import (
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
@@ -186,9 +188,16 @@ const awayTimeLayout = "15:04"
func gameDTOFromGame(g game.Game) gameDTO {
seats := make([]seatDTO, 0, len(g.Seats))
for _, s := range g.Seats {
// An open game's still-empty opponent seat has no account: emit an empty id (the
// display name is left empty by fillSeatNames) so the client shows "searching for
// opponent" rather than the nil-UUID.
accountID := ""
if s.AccountID != uuid.Nil {
accountID = s.AccountID.String()
}
seats = append(seats, seatDTO{
Seat: s.Seat,
AccountID: s.AccountID.String(),
AccountID: accountID,
Score: s.Score,
HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner,
@@ -277,13 +286,12 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
return dto, nil
}
// matchDTOFrom projects an enqueue/poll result into its DTO.
// matchDTOFrom projects an enqueue result into its DTO. Enqueue always lands the
// caller in a game (freshly opened or joined), so the game is always present; Matched
// reports whether it already had an opponent.
func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
if !r.Matched {
return matchDTO{Matched: false}
}
g := gameDTOFromGame(r.Game)
return matchDTO{Matched: true, Game: &g}
return matchDTO{Matched: r.Matched, Game: &g}
}
// chatDTOFrom projects a chat message into its DTO.
+2 -4
View File
@@ -76,8 +76,6 @@ func (s *Server) registerRoutes() {
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
u.POST("/lobby/cancel", s.handleCancel)
u.GET("/lobby/poll", s.handlePoll)
}
if s.invitations != nil {
u.GET("/invitations", s.handleListInvitations)
@@ -161,14 +159,14 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "nudge_own_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished"
case errors.Is(err, game.ErrNoOpponentYet):
return http.StatusConflict, "no_opponent_yet"
case errors.Is(err, game.ErrGameActive):
return http.StatusConflict, "game_active"
case errors.Is(err, account.ErrInvalidProfile):
return http.StatusBadRequest, "invalid_profile"
case errors.Is(err, account.ErrAlreadyConfirmed):
return http.StatusConflict, "already_confirmed"
case errors.Is(err, lobby.ErrAlreadyQueued):
return http.StatusConflict, "already_queued"
case errors.Is(err, lobby.ErrInvalidInvitation):
return http.StatusBadRequest, "invalid_invitation"
case errors.Is(err, lobby.ErrInvitationBlocked):
+4 -35
View File
@@ -133,13 +133,15 @@ func (s *Server) handleGameState(c *gin.Context) {
c.JSON(http.StatusOK, dto)
}
// enqueueRequest joins the per-variant auto-match pool under a per-turn word rule.
// enqueueRequest enters per-variant auto-match under a per-turn word rule.
type enqueueRequest struct {
Variant string `json:"variant"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
}
// handleEnqueue joins the auto-match pool for a variant.
// handleEnqueue enters the caller into auto-match for a variant and returns the game
// they land in immediately: a freshly opened game awaiting an opponent, or another
// player's open game they just joined. The client navigates straight into the game.
func (s *Server) handleEnqueue(c *gin.Context) {
uid, ok := userID(c)
if !ok {
@@ -168,39 +170,6 @@ func (s *Server) handleEnqueue(c *gin.Context) {
c.JSON(http.StatusOK, dto)
}
// handleCancel removes the caller from the auto-match pool (and drops any pending
// matched result), so a cancelled quick-match neither blocks a re-queue nor later
// surfaces a robot-substituted game the player abandoned. It is idempotent: cancelling
// when not queued is a no-op success.
func (s *Server) handleCancel(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
s.matchmaker.Cancel(c.Request.Context(), uid)
c.Status(http.StatusNoContent)
}
// handlePoll reports whether the caller has been paired since queueing.
func (s *Server) handlePoll(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
res, err := s.matchmaker.Poll(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
dto := matchDTOFrom(res)
if dto.Game != nil {
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{})
}
c.JSON(http.StatusOK, dto)
}
// chatPostRequest posts a per-game chat message.
type chatPostRequest struct {
Body string `json:"body"`
+38 -25
View File
@@ -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 **090 s** (so **90180 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
+11 -6
View File
@@ -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.53 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.53 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
+12 -8
View File
@@ -42,9 +42,9 @@ nudge) приходят от бота **этой партии** — по язы
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без
перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья)
чат, nudge и подключение соперника к игре, в которой вы ждёте. Каждое обновление приходит самим событием и применяется на месте без
перезагрузки — доска обновляется бесшовно, а приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, конец партии, nudge, приглашение или заявка в друзья)
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
уведомления только в приложении (настройка профиля, **включена по умолчанию**).
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
@@ -87,9 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране
любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо
в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
игре; если такого нет — через **1,53 минуты** свободное место занимает робот, так что игра всегда
стартует, и экран новой игры подсказывает, что можно закрыть приложение на время ожидания и вернуться
позже. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
@@ -126,8 +130,8 @@ nudge) приходят от бота **этой партии** — по язы
предпросмотр счёта и отправка доступны лишь в собственный ход.
### Робот-соперник
Если авто-подбор не находит человека за десять секунд, свободное место занимает
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
Если авто-подбор не находит человека за время ожидания (1,5–3 минуты), свободное место в игре,
в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека:
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
+6 -1
View File
@@ -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.
+31
View File
@@ -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();
});
+6 -2
View File
@@ -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()}
/>
<button class="iconbtn" onclick={send} disabled={busy || !connection.online} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={send} disabled={busy || waiting || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if}
</div>
</div>
+6 -2
View File
@@ -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 @@
}
</script>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
+31 -9
View File
@@ -79,7 +79,7 @@
let recentFlash = $state(false);
function refreshRecent() {
const v = view;
if (!v || v.game.status !== 'active') {
if (!v || v.game.status === 'finished') {
recent = new Set();
recentFlash = false;
return;
@@ -98,8 +98,12 @@
});
const slots = $derived(rackView(placement));
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
// 'open' is an auto-match game still waiting for an opponent: the starter may move on their
// turn just like an active game, so "playable" covers both; only 'finished' is over.
const waitingForOpponent = $derived(!!view && view.game.status === 'open');
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished');
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
// The seat whose move the history grid awaits with a "thinking…" placeholder: the player to
// move while the game is active, but never the viewer themselves (their own pending cell
@@ -215,6 +219,13 @@
if (view && e.moveCount > view.game.moveCount) void load();
} else if (e.kind === 'game_over' && e.gameId === id) {
applyDelta(applyGameOver(cacheSnapshot(), e.game));
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
// The opponent took the empty seat: adopt the new participants and status in place,
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
if (view) {
view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } };
setCachedGame(id, view, moves);
}
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
// A request the player sent was answered: re-derive the in-game "add friend" state.
void loadFriends();
@@ -748,10 +759,21 @@
}
}
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is
// not yet a friend (an already-requested opponent still shows it, but disabled).
// seatName renders a seat's name: "you" for the viewer, the localized "searching for
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
// display name.
function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you');
if (!s.accountId) return t('game.searchingForOpponent');
return s.displayName;
}
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled).
function canAddFriend(accountId: string): boolean {
return !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
}
</script>
@@ -763,7 +785,7 @@
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="nm">{seatName(s)}</div>
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
{#if historyOpen && canAddFriend(s.accountId)}
<span class="addfriend">
@@ -788,7 +810,7 @@
{#if gameOver}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{:else}
<button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button>
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
@@ -855,7 +877,7 @@
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else if placement.pending.length === 0}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : seatName(view.game.seats[view.game.toMove])}</span>
{/if}
<span class="scores">
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
+14
View File
@@ -4,6 +4,7 @@ import * as fb from '../gen/fbs/scrabblefb';
import { BLANK_INDEX, setAlphabet } from './alphabet';
import {
decodeDraftView,
decodeEvent,
decodeFriendList,
decodeGameList,
decodeInvitation,
@@ -264,6 +265,19 @@ describe('codec', () => {
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
expect(inv.variant).toBe('scrabble_en');
});
it('decodes an opponent_joined event (reusing the match_found payload layout)', () => {
const b = new Builder(64);
const gid = b.createString('g-open');
fb.MatchFoundEvent.startMatchFoundEvent(b);
fb.MatchFoundEvent.addGameId(b, gid);
b.finish(fb.MatchFoundEvent.endMatchFoundEvent(b));
expect(decodeEvent('opponent_joined', b.asUint8Array())).toEqual({
kind: 'opponent_joined',
gameId: 'g-open',
state: undefined,
});
});
});
// The live play loop exchanges alphabet indices, mapped through the per-variant
+6
View File
@@ -465,6 +465,12 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
const st = e.state();
return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
}
case 'opponent_joined': {
// opponent_joined reuses the match_found payload layout (game id + the recipient's state).
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
const st = e.state();
return { kind: 'opponent_joined', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
}
case 'notify': {
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
const acc = e.account();
+5
View File
@@ -20,4 +20,9 @@ if (isMock && typeof window !== 'undefined') {
offline: reportOffline,
online: reportOnline,
};
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(window as unknown as { __mock?: { joinOpponent(): void } }).__mock = {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
};
}
+3
View File
@@ -51,11 +51,14 @@ export const en = {
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
'new.moveLimit': 'Move time: {n} h 00 min',
'new.searchHint':
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
'game.bag': '{n} in the bag',
'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn',
'game.searchingForOpponent': 'Searching for opponent…',
'game.waiting': "Waiting for {name}",
'game.makeMove': 'Make move',
'game.reset': 'Reset',
+3
View File
@@ -52,11 +52,14 @@ export const ru: Record<MessageKey, string> = {
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход',
'game.searchingForOpponent': 'Поиск соперника...',
'game.waiting': 'Ожидаем {name}',
'game.makeMove': 'Сделать ход',
'game.reset': 'Сброс',
+35 -8
View File
@@ -88,6 +88,8 @@ export class MockGateway implements GatewayClient {
private readonly profile: Profile = { ...PROFILE };
private readonly subs = new Set<(e: PushEvent) => void>();
private pendingMatch: string | null = null;
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
private openGameId: string | null = null;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
private outgoing: AccountRef[] = [];
@@ -143,14 +145,16 @@ export class MockGateway implements GatewayClient {
// --- lobby ---
async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> {
// Simulate a 10s-style robot substitution, sped up: match found shortly.
// The player enters an open game immediately and waits inside it; a robot opponent takes
// the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing
// opponent_joined so the game UI restores from the "searching for opponent" state.
const id = crypto.randomUUID();
const g: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'active',
status: 'open',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
@@ -160,7 +164,7 @@ export class MockGateway implements GatewayClient {
lastActivityUnix: Math.floor(Date.now() / 1000),
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
@@ -170,9 +174,28 @@ export class MockGateway implements GatewayClient {
chat: [],
};
this.games.set(id, g);
this.pendingMatch = id;
setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400);
return { matched: false };
this.openGameId = id;
// The opponent joins on a timer for manual mock play; the e2e triggers it deterministically
// through the __mock hook (see lib/gateway.ts).
setTimeout(() => this.fillOpponent(id), 3000);
return { matched: false, game: structuredClone(g.view) };
}
// fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
// mock of a human or robot taking the seat. A no-op once the game is no longer open.
private fillOpponent(id: string): void {
const game = this.games.get(id);
if (!game || game.view.status !== 'open') return;
game.view.status = 'active';
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
}
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
// opponent to the most recently opened game on demand, making the waiting → joined transition
// deterministic.
joinPendingOpponent(): void {
if (this.openGameId) this.fillOpponent(this.openGameId);
}
async lobbyPoll(): Promise<MatchResult> {
@@ -190,8 +213,8 @@ export class MockGateway implements GatewayClient {
}
// --- game ---
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
const g = this.game(gameId);
// stateOf builds a player's StateView from a mock game (the viewer is always ME).
private stateOf(g: MockGame): StateView {
return {
game: structuredClone(g.view),
seat: this.mySeat(g),
@@ -201,6 +224,10 @@ export class MockGateway implements GatewayClient {
};
}
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
return this.stateOf(this.game(gameId));
}
async gameHistory(gameId: string): Promise<History> {
const g = this.game(gameId);
return { gameId, moves: structuredClone(g.moves) };
+6 -4
View File
@@ -5,8 +5,9 @@
export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';
/** Backend game status strings. */
export type GameStatus = 'active' | 'finished' | string;
/** Backend game status strings. 'open' is an auto-match game the player has entered
* but which is still waiting for an opponent (the opponent seat has no account). */
export type GameStatus = 'active' | 'finished' | 'open' | string;
/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */
export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string;
@@ -237,8 +238,8 @@ export interface GameList {
/**
* A live event delivered over the Subscribe stream. The game events carry the move as a
* delta — move plus the post-move summary (and the bag size) — the client applies to its
* cached game without a refetch; match_found / game_started carry the recipient's initial
* StateView; notify carries the changed lobby payload. The enriched fields are optional
* cached game without a refetch; match_found / game_started / opponent_joined carry the
* recipient's StateView; notify carries the changed lobby payload. The enriched fields are optional
* so a client falls back to a refetch when a payload is absent (a gap, or an older peer).
*/
export type PushEvent =
@@ -248,5 +249,6 @@ export type PushEvent =
| { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string }
| { kind: 'match_found'; gameId: string; state?: StateView }
| { kind: 'opponent_joined'; gameId: string; state?: StateView }
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
| { kind: 'heartbeat' };
+2
View File
@@ -52,6 +52,8 @@
const groups = $derived(groupGames(games, myId));
function opponents(g: GameView): string {
// An auto-match game still waiting for an opponent shows the "searching" placeholder.
if (g.status === 'open') return t('game.searchingForOpponent');
return g.seats
.filter((s) => s.accountId !== myId)
.map((s) => s.displayName)
+18 -105
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
@@ -41,79 +41,25 @@
let mode = $state<'auto' | 'friends'>('auto');
// --- auto-match ---
let searching = $state(false);
// matched guards the teardown: once a match arrives (immediately, via the match_found push, or
// via the fallback poll) onDestroy must not dequeue the game we just got.
let matched = $state(false);
let poll: ReturnType<typeof setInterval> | null = null;
function stop() {
if (poll) {
clearInterval(poll);
poll = null;
}
}
// startPoll is the matchmaking fallback used only while the live stream is down: with the stream
// up the match_found push drives navigation. It polls lobby.poll every 2.5s.
function startPoll() {
if (poll) return;
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
matched = true;
searching = false;
stop();
navigate(`/game/${p.game.id}`);
}
} catch (e) {
handleError(e);
}
}, 2500);
}
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
// reaper later substitutes a robot for a game the player abandoned.
function cancelSearch() {
stop();
searching = false;
void gateway.lobbyCancel().catch(() => {});
navigate('/');
}
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
// opponent, or another player's open game they just joined — so we navigate into it at once
// and the player waits inside. The opponent (a human or, after the wait, a robot) takes the
// empty seat later via the opponent_joined push; there is no separate "searching" screen.
let starting = $state(false);
async function find(v: Variant) {
searching = true;
matched = false;
if (starting) return;
starting = true;
try {
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords));
if (r.matched && r.game) {
matched = true;
searching = false;
navigate(`/game/${r.game.id}`);
}
if (r.game) navigate(`/game/${r.game.id}`);
} catch (e) {
searching = false;
handleError(e);
} finally {
starting = false;
}
// No immediate match: wait for the match_found push; the effect below polls only when the
// stream is down.
}
// Poll for the match only while searching and the stream is down (the push cannot reach us);
// stop once the stream is back or the search ends.
$effect(() => {
if (searching && !app.streamAlive) startPoll();
else stop();
});
// match_found is handled globally (app.svelte navigates); end the search here too so onDestroy
// does not cancel the match we just received.
$effect(() => {
if (app.lastEvent?.kind === 'match_found' && searching) {
matched = true;
searching = false;
}
});
// --- friend game ---
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
@@ -161,23 +107,10 @@
}
}
onDestroy(() => {
stop();
// Abandoned mid-search without a match (navigated away without Cancel): dequeue so we don't
// linger. A received match (matched) must not be cancelled.
if (searching && !matched) void gateway.lobbyCancel().catch(() => {});
});
</script>
<Screen title={t('new.title')} back="/">
<div class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={cancelSearch}>{t('common.cancel')}</button>
</div>
{:else}
{#if !guest}
<div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
@@ -214,9 +147,10 @@
</label>
{/if}
<p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<p class="searchhint">{t('new.searchHint')}</p>
<button
class="invite"
disabled={!selectedAuto || !connection.online}
disabled={!selectedAuto || !connection.online || starting}
onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button>
{:else if friends.length === 0}
@@ -266,7 +200,6 @@
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{/if}
</div>
</Screen>
@@ -460,31 +393,11 @@
.invite:disabled {
opacity: 0.5;
}
.searching {
display: grid;
place-items: center;
gap: 14px;
padding: 48px 0;
.searchhint {
margin: 0;
text-align: center;
color: var(--text-muted);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cancel {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 0.85rem;
line-height: 1.4;
}
</style>