feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place. Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent". Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -145,6 +145,96 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
return svc.store.GetGame(ctx, id)
|
||||
}
|
||||
|
||||
// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
|
||||
// params and returns the game they land in immediately: another waiting player's open
|
||||
// game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open
|
||||
// game seating only the caller with an empty opponent seat that a human or the reaper's
|
||||
// robot fills later. openDeadline is when the reaper substitutes a robot into a freshly
|
||||
// opened game (ignored when joining one). The bag seed defaults to random; params.Seed
|
||||
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
|
||||
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
|
||||
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
|
||||
if _, err := svc.accounts.GetByID(ctx, accountID); err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID)
|
||||
}
|
||||
return Game{}, false, err
|
||||
}
|
||||
timeout := params.TurnTimeout
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTurnTimeout
|
||||
}
|
||||
if !allowedTimeout(timeout) {
|
||||
return Game{}, false, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
|
||||
}
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Game{}, false, fmt.Errorf("game: new id: %w", err)
|
||||
}
|
||||
seed := params.Seed
|
||||
if seed == 0 {
|
||||
seed = svc.rng()
|
||||
}
|
||||
deadline := openDeadline
|
||||
ins := gameInsert{
|
||||
id: id,
|
||||
variant: params.Variant.String(),
|
||||
dictVersion: svc.version,
|
||||
seed: seed,
|
||||
players: 2,
|
||||
turnTimeoutSecs: int(timeout / time.Second),
|
||||
hintsAllowed: params.HintsAllowed,
|
||||
hintsPerPlayer: params.HintsPerPlayer,
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
}
|
||||
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat
|
||||
// is left empty (uuid.Nil) for the opponent.
|
||||
seats := []uuid.UUID{accountID, uuid.Nil}
|
||||
if seed&1 == 1 {
|
||||
seats = []uuid.UUID{uuid.Nil, accountID}
|
||||
}
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
if created {
|
||||
svc.metrics.recordStarted(ctx, params.Variant)
|
||||
}
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
return g, joined, nil
|
||||
}
|
||||
|
||||
// AttachRobot seats robotID in the empty opponent seat of open game gameID and flips
|
||||
// it to active, returning the now-active game and whether it attached (false, with a
|
||||
// zero Game, when a human joined first). It backs the matchmaking reaper.
|
||||
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (Game, bool, error) {
|
||||
attached, err := svc.store.AttachRobot(ctx, gameID, robotID)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
if !attached {
|
||||
return Game{}, false, nil
|
||||
}
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
return g, true, nil
|
||||
}
|
||||
|
||||
// ExpiredOpen returns the open games due for a robot substitution (deadline at or
|
||||
// before now) for the matchmaking reaper.
|
||||
func (svc *Service) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
|
||||
return svc.store.ExpiredOpen(ctx, now)
|
||||
}
|
||||
|
||||
// engineOp applies one transition to the live game, returning the decoded record
|
||||
// and, for an exchange, the swapped tiles.
|
||||
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
|
||||
@@ -189,6 +279,11 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
|
||||
if !ok {
|
||||
return MoveResult{}, ErrNotAPlayer
|
||||
}
|
||||
// Resign needs a present opponent to award the win, so it is refused while the game
|
||||
// is still waiting for one; the UI keeps the button disabled until then.
|
||||
if pre.Status == StatusOpen {
|
||||
return MoveResult{}, ErrNoOpponentYet
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
return MoveResult{}, ErrFinished
|
||||
}
|
||||
@@ -260,7 +355,10 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
if !ok {
|
||||
return MoveResult{}, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
// A move is allowed while the game is active or still open (the starter may move on
|
||||
// their turn before an opponent joins); only a finished game rejects it. The turn
|
||||
// check below keeps the starter off the still-empty opponent seat.
|
||||
if pre.Status == StatusFinished {
|
||||
return MoveResult{}, ErrFinished
|
||||
}
|
||||
if pre.ToMove != seat {
|
||||
@@ -382,6 +480,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
summary := gameSummary(post, names)
|
||||
intents := make([]notify.Intent, 0, 2*len(post.Seats))
|
||||
for _, s := range post.Seats {
|
||||
if s.AccountID == uuid.Nil {
|
||||
continue // an open game's opponent seat is not yet filled — nobody to notify
|
||||
}
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
|
||||
}
|
||||
// Game pushes are routed out-of-app by the game's own language, not the recipient's
|
||||
@@ -406,6 +507,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
// seat, each with their own perspective + recipient-first score, so an offline player gets
|
||||
// an out-of-app "game over" push (online players take it from the in-app refresh).
|
||||
for _, s := range post.Seats {
|
||||
if s.AccountID == uuid.Nil {
|
||||
continue
|
||||
}
|
||||
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
|
||||
over.Language = lang
|
||||
intents = append(intents, over)
|
||||
@@ -540,7 +644,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
|
||||
if _, ok := pre.seatOf(accountID); !ok {
|
||||
return EvalResult{}, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
if pre.Status == StatusFinished {
|
||||
return EvalResult{}, ErrFinished
|
||||
}
|
||||
|
||||
@@ -674,7 +778,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
if !ok {
|
||||
return HintResult{}, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
if pre.Status == StatusFinished {
|
||||
return HintResult{}, ErrFinished
|
||||
}
|
||||
if pre.ToMove != seat {
|
||||
@@ -736,7 +840,7 @@ func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
if !ok {
|
||||
return nil, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
if pre.Status == StatusFinished {
|
||||
return nil, ErrFinished
|
||||
}
|
||||
if pre.ToMove != seat {
|
||||
|
||||
+196
-15
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
@@ -40,6 +41,13 @@ type gameInsert struct {
|
||||
dropoutTiles string
|
||||
// multipleWordsPerTurn false selects the single-word rule for the game.
|
||||
multipleWordsPerTurn bool
|
||||
// status is the lifecycle state to create the game in: StatusActive for a normal
|
||||
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
||||
// empty string defaults to StatusActive.
|
||||
status string
|
||||
// openDeadline, set only for a StatusOpen game, is when the matchmaking reaper
|
||||
// substitutes a robot if no human has joined; nil for a normal game.
|
||||
openDeadline *time.Time
|
||||
}
|
||||
|
||||
// statDelta is one account's contribution to its statistics on a game finish.
|
||||
@@ -91,24 +99,186 @@ type activeGame struct {
|
||||
// first) inside a single transaction.
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
gi := table.Games.INSERT(
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn)
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
return insertGameTx(ctx, tx, ins, seats)
|
||||
})
|
||||
}
|
||||
|
||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||
// first) on tx. A seat whose account id is uuid.Nil is written with a NULL
|
||||
// account_id — the still-empty opponent seat of a StatusOpen auto-match game.
|
||||
func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.UUID) error {
|
||||
status := ins.status
|
||||
if status == "" {
|
||||
status = StatusActive
|
||||
}
|
||||
var deadline any = postgres.NULL
|
||||
if ins.openDeadline != nil {
|
||||
deadline = postgres.TimestampzT(*ins.openDeadline)
|
||||
}
|
||||
gi := table.Games.INSERT(
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn)
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
for seat, accountID := range seats {
|
||||
var acc any = accountID
|
||||
if accountID == uuid.Nil {
|
||||
acc = postgres.NULL
|
||||
}
|
||||
for seat, accountID := range seats {
|
||||
pi := table.GamePlayers.INSERT(
|
||||
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).VALUES(ins.id, seat, accountID)
|
||||
if _, err := pi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert seat %d: %w", seat, err)
|
||||
pi := table.GamePlayers.INSERT(
|
||||
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).VALUES(ins.id, seat, acc)
|
||||
if _, err := pi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert seat %d: %w", seat, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the
|
||||
// advisory-lock key that serialises concurrent enqueues for that bucket, so two
|
||||
// players never both open a game instead of pairing.
|
||||
func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(variant))
|
||||
if multipleWords {
|
||||
_, _ = h.Write([]byte{1})
|
||||
} else {
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it
|
||||
// lands in: it re-uses the caller's own still-open game (joined=false, created=false,
|
||||
// a re-enqueue is idempotent), joins another player's waiting open game and flips it
|
||||
// active (joined=true), or opens a fresh game seating the caller with an empty
|
||||
// opponent seat (created=true). ins supplies the new game's immutable fields and is
|
||||
// used only when a game is created. A transaction-scoped advisory lock on the
|
||||
// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
|
||||
// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
|
||||
// for the still-empty opponent, in the chosen order) used only when a game is created.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameInsert, seats []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
return fmt.Errorf("open match lock: %w", e)
|
||||
}
|
||||
// 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent.
|
||||
var own uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT g.game_id FROM backend.games g
|
||||
JOIN backend.game_players p ON p.game_id = g.game_id
|
||||
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3
|
||||
LIMIT 1`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); {
|
||||
case e == nil:
|
||||
gameID = own
|
||||
return nil
|
||||
case !errors.Is(e, sql.ErrNoRows):
|
||||
return fmt.Errorf("find own open game: %w", e)
|
||||
}
|
||||
// 2. Another player's open game waiting for an opponent — fill its seat and start it.
|
||||
var other uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT g.game_id FROM backend.games g
|
||||
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
|
||||
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
|
||||
WHERE p.game_id = g.game_id AND p.account_id = $3)
|
||||
ORDER BY g.created_at
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
|
||||
case e == nil:
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID); er != nil {
|
||||
return er
|
||||
}
|
||||
gameID, joined = other, true
|
||||
return nil
|
||||
case !errors.Is(e, sql.ErrNoRows):
|
||||
return fmt.Errorf("find open game: %w", e)
|
||||
}
|
||||
// 3. None waiting — open a fresh game seating the caller (the other seat empty).
|
||||
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
|
||||
return e
|
||||
}
|
||||
gameID, created = ins.id, true
|
||||
return nil
|
||||
})
|
||||
return gameID, joined, created, err
|
||||
}
|
||||
|
||||
// AttachRobot fills the empty opponent seat of open game gameID with robotID and
|
||||
// flips it to active, returning whether it attached. It is a no-op (false) when the
|
||||
// game is no longer open — a human joined first — so the reaper never double-fills.
|
||||
func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (bool, error) {
|
||||
attached := false
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
var status string
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT status FROM backend.games WHERE game_id = $1 FOR UPDATE`, gameID).Scan(&status); {
|
||||
case errors.Is(e, sql.ErrNoRows):
|
||||
return nil
|
||||
case e != nil:
|
||||
return fmt.Errorf("lock game for robot: %w", e)
|
||||
}
|
||||
if status != StatusOpen {
|
||||
return nil
|
||||
}
|
||||
if e := fillOpenSeat(ctx, tx, gameID, robotID); e != nil {
|
||||
return e
|
||||
}
|
||||
attached = true
|
||||
return nil
|
||||
})
|
||||
return attached, err
|
||||
}
|
||||
|
||||
// fillOpenSeat seats accountID in an open game's empty opponent seat and flips the
|
||||
// game to active, stamping a fresh turn clock. The caller holds the game row.
|
||||
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.game_players SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
|
||||
gameID, accountID); err != nil {
|
||||
return fmt.Errorf("fill opponent seat: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
|
||||
WHERE game_id = $1`, gameID); err != nil {
|
||||
return fmt.Errorf("activate game: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpiredOpen returns the open games whose robot deadline has passed (at or before
|
||||
// now), oldest deadline first, for the matchmaking reaper to fill with a robot.
|
||||
func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT game_id, variant FROM backend.games
|
||||
WHERE status = 'open' AND open_deadline_at IS NOT NULL AND open_deadline_at <= $1
|
||||
ORDER BY open_deadline_at`, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game: expired open: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []OpenGame
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var variantStr string
|
||||
if err := rows.Scan(&id, &variantStr); err != nil {
|
||||
return nil, fmt.Errorf("game: scan expired open: %w", err)
|
||||
}
|
||||
variant, err := engine.ParseVariant(variantStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game: expired open %s: %w", id, err)
|
||||
}
|
||||
out = append(out, OpenGame{ID: id, Variant: variant})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetGame loads the games row joined with its seats (ordered by seat), or
|
||||
@@ -670,9 +840,14 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
|
||||
}
|
||||
out := make([]RobotTurn, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
// The filter matches only the robot's (non-null) seat, so AccountID is set.
|
||||
robotID := uuid.Nil
|
||||
if r.GamePlayers.AccountID != nil {
|
||||
robotID = *r.GamePlayers.AccountID
|
||||
}
|
||||
out = append(out, RobotTurn{
|
||||
GameID: r.Games.GameID,
|
||||
RobotID: r.GamePlayers.AccountID,
|
||||
RobotID: robotID,
|
||||
RobotSeat: int(r.GamePlayers.Seat),
|
||||
ToMove: int(r.Games.ToMove),
|
||||
TurnStartedAt: r.Games.TurnStartedAt,
|
||||
@@ -773,9 +948,15 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
}
|
||||
out.Seats = make([]Seat, 0, len(seats))
|
||||
for _, p := range seats {
|
||||
// A NULL account_id is the still-empty opponent seat of an open game; surface it
|
||||
// as uuid.Nil so callers keep the "seats == players" invariant.
|
||||
accountID := uuid.Nil
|
||||
if p.AccountID != nil {
|
||||
accountID = *p.AccountID
|
||||
}
|
||||
out.Seats = append(out.Seats, Seat{
|
||||
Seat: int(p.Seat),
|
||||
AccountID: p.AccountID,
|
||||
AccountID: accountID,
|
||||
Score: int(p.Score),
|
||||
HintsUsed: int(p.HintsUsed),
|
||||
IsWinner: p.IsWinner,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,22 +5,30 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config configures the matchmaking pool's robot substitution.
|
||||
// Config configures auto-match robot substitution: how long an open game waits for a
|
||||
// human opponent before a robot is substituted, and how often the reaper scans.
|
||||
type Config struct {
|
||||
// RobotWait is how long an auto-match player waits for a human before a robot
|
||||
// is substituted. Sourced from BACKEND_LOBBY_ROBOT_WAIT.
|
||||
// RobotWait is the fixed minimum an open auto-match game waits for a human
|
||||
// opponent before it is eligible for robot substitution. Sourced from
|
||||
// BACKEND_LOBBY_ROBOT_WAIT.
|
||||
RobotWait time.Duration
|
||||
// ReaperInterval is how often the substitution reaper scans for over-waited
|
||||
// players. Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
|
||||
// RobotWaitJitter is a random extra wait in [0, RobotWaitJitter) added on top of
|
||||
// RobotWait per game, so the substitution time varies. Sourced from
|
||||
// BACKEND_LOBBY_ROBOT_WAIT_JITTER.
|
||||
RobotWaitJitter time.Duration
|
||||
// ReaperInterval is how often the reaper scans for open games due for a robot.
|
||||
// Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
|
||||
ReaperInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns the matchmaking defaults: a 10-second wait
|
||||
// (docs/ARCHITECTURE.md §7) scanned every second.
|
||||
// DefaultConfig returns the matchmaking defaults: a guaranteed 90-second wait for a
|
||||
// human plus up to 90 random seconds (90–180 s total) before a robot substitutes
|
||||
// (docs/ARCHITECTURE.md §7), scanned every five seconds.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
RobotWait: 10 * time.Second,
|
||||
ReaperInterval: time.Second,
|
||||
RobotWait: 90 * time.Second,
|
||||
RobotWaitJitter: 90 * time.Second,
|
||||
ReaperInterval: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +37,9 @@ func (c Config) Validate() error {
|
||||
if c.RobotWait <= 0 {
|
||||
return fmt.Errorf("lobby: robot wait must be positive, got %s", c.RobotWait)
|
||||
}
|
||||
if c.RobotWaitJitter < 0 {
|
||||
return fmt.Errorf("lobby: robot wait jitter must not be negative, got %s", c.RobotWaitJitter)
|
||||
}
|
||||
if c.ReaperInterval <= 0 {
|
||||
return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -2,8 +2,7 @@ package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -14,182 +13,91 @@ import (
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// matchKey buckets the auto-match pool: two players are paired only when they chose
|
||||
// the same variant and the same per-turn word rule (multipleWords), so a game always
|
||||
// starts under a rule both players asked for.
|
||||
type matchKey struct {
|
||||
variant engine.Variant
|
||||
multipleWords bool
|
||||
// GameMatcher is the slice of the game domain the matchmaker drives: opening or
|
||||
// joining an auto-match game, substituting a robot into one whose wait elapsed, and
|
||||
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
|
||||
// it.
|
||||
type GameMatcher interface {
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
|
||||
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error)
|
||||
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
}
|
||||
|
||||
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
|
||||
// the next two humans into a two-player game, or — when no human arrives within
|
||||
// the wait window — substitutes a robot. It holds no database state and is lost on
|
||||
// restart (players simply re-queue). It is safe for concurrent use.
|
||||
// Matchmaker turns an auto-match enqueue into a real game the player enters at once:
|
||||
// it opens a game with an empty opponent seat, or joins the caller into another
|
||||
// player's waiting one. A background reaper substitutes a pooled robot for any open
|
||||
// game whose wait window has elapsed, guaranteeing every game gets an opponent. All
|
||||
// matchmaking state is the open games in the database, so it survives a restart; the
|
||||
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
|
||||
// concurrent use.
|
||||
//
|
||||
// Auto-match is anonymous, so the pool does not consult per-user blocks (those
|
||||
// govern friends, chat and invitations between known players).
|
||||
//
|
||||
// A player who is queued learns of a match — by a waiting human being paired, or
|
||||
// by robot substitution — through Poll, the interim delivery seam: production
|
||||
// delivery is a notification (session/in-app push and the platform side-service,
|
||||
// docs/ARCHITECTURE.md §10), wired with the gateway in a later stage.
|
||||
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
|
||||
// friends, chat and invitations between known players).
|
||||
type Matchmaker struct {
|
||||
games GameCreator
|
||||
robots RobotProvider
|
||||
waitDelay time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
log *zap.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
queues map[matchKey][]uuid.UUID
|
||||
queued map[uuid.UUID]matchKey
|
||||
waitingSince map[uuid.UUID]time.Time
|
||||
results map[uuid.UUID]game.Game
|
||||
rng *rand.Rand
|
||||
games GameMatcher
|
||||
robots RobotProvider
|
||||
minWait time.Duration
|
||||
jitter time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewMatchmaker constructs a Matchmaker that starts matched games through games
|
||||
// and substitutes a robot from robots when a player waits longer than waitDelay.
|
||||
func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Duration, log *zap.Logger) *Matchmaker {
|
||||
// NewMatchmaker constructs a Matchmaker that opens auto-match games through games and,
|
||||
// after a per-game wait of minWait plus a random jitter in [0, jitter), substitutes a
|
||||
// pooled robot from robots when no human has joined.
|
||||
func NewMatchmaker(games GameMatcher, robots RobotProvider, minWait, jitter time.Duration, log *zap.Logger) *Matchmaker {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &Matchmaker{
|
||||
games: games,
|
||||
robots: robots,
|
||||
waitDelay: waitDelay,
|
||||
clock: func() time.Time { return time.Now().UTC() },
|
||||
pub: notify.Nop{},
|
||||
log: log,
|
||||
queues: make(map[matchKey][]uuid.UUID),
|
||||
queued: make(map[uuid.UUID]matchKey),
|
||||
waitingSince: make(map[uuid.UUID]time.Time),
|
||||
results: make(map[uuid.UUID]game.Game),
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
games: games,
|
||||
robots: robots,
|
||||
minWait: minWait,
|
||||
jitter: jitter,
|
||||
clock: func() time.Time { return time.Now().UTC() },
|
||||
pub: notify.Nop{},
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotifier installs the live-event publisher used to push match_found to the
|
||||
// seated players when a pairing or robot substitution starts a game. It must be
|
||||
// called during startup wiring, before the reaper runs; the default is
|
||||
// notify.Nop (no live events; waiters still discover the game via Poll).
|
||||
// SetNotifier installs the live-event publisher used to push opponent_joined to a
|
||||
// waiting starter when a human or a robot takes the empty seat. It must be called
|
||||
// during startup wiring, before the reaper runs; the default is notify.Nop (no live
|
||||
// events).
|
||||
func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
if p != nil {
|
||||
m.pub = p
|
||||
}
|
||||
}
|
||||
|
||||
// emitMatchFound pushes match_found to every seat of a freshly started game.
|
||||
// Emitting to a robot seat is harmless (no client subscription exists for it).
|
||||
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) {
|
||||
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot
|
||||
intents := make([]notify.Intent, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
|
||||
if err != nil {
|
||||
// A waiter still discovers the game through Poll (the ws-down fallback), so skip the
|
||||
// enriched push for this seat rather than failing the match.
|
||||
m.log.Warn("match_found initial state",
|
||||
zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
mf := notify.MatchFound(s.AccountID, g.ID, state)
|
||||
mf.Language = lang
|
||||
intents = append(intents, mf)
|
||||
}
|
||||
m.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// EnqueueResult reports the outcome of joining the pool: either a started game or a
|
||||
// queued ticket awaiting an opponent.
|
||||
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
|
||||
// in, and whether it already had an opponent (they joined a waiting game) rather than
|
||||
// being freshly opened and still awaiting one.
|
||||
type EnqueueResult struct {
|
||||
Matched bool
|
||||
Game game.Game
|
||||
}
|
||||
|
||||
// Enqueue joins accountID to the auto-match pool for variant under the chosen
|
||||
// per-turn word rule (multipleWords). If an opponent already waits for the same
|
||||
// variant and rule, the two are paired (seat order randomised for first-move
|
||||
// fairness) and a game starts immediately; otherwise the account waits, and a later
|
||||
// pairing or robot substitution is delivered through Poll. An account already waiting
|
||||
// in any pool gets ErrAlreadyQueued.
|
||||
// Enqueue resolves an auto-match request for accountID under variant and the per-turn
|
||||
// word rule (multipleWords) into the game they enter immediately — a freshly opened
|
||||
// game awaiting an opponent, the caller's own still-open game (a re-enqueue is
|
||||
// idempotent), or another player's open game they just joined. When the caller joins
|
||||
// an existing game, opponent_joined is pushed to that game's waiting starter.
|
||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
||||
key := matchKey{variant: variant, multipleWords: multipleWords}
|
||||
m.mu.Lock()
|
||||
if _, ok := m.queued[accountID]; ok {
|
||||
m.mu.Unlock()
|
||||
return EnqueueResult{}, ErrAlreadyQueued
|
||||
}
|
||||
q := m.queues[key]
|
||||
if len(q) == 0 {
|
||||
m.queues[key] = append(q, accountID)
|
||||
m.queued[accountID] = key
|
||||
m.waitingSince[accountID] = m.clock()
|
||||
m.mu.Unlock()
|
||||
return EnqueueResult{}, nil
|
||||
}
|
||||
opponent := q[0]
|
||||
m.removeLocked(opponent, key)
|
||||
seats := []uuid.UUID{opponent, accountID}
|
||||
if m.rng.Intn(2) == 0 {
|
||||
seats[0], seats[1] = seats[1], seats[0]
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
g, err := m.games.Create(ctx, autoMatchParams(key, seats))
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
// The opponent was waiting; record the game so they can collect it via Poll.
|
||||
m.mu.Lock()
|
||||
m.results[opponent] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(ctx, g)
|
||||
return EnqueueResult{Matched: true, Game: g}, nil
|
||||
}
|
||||
|
||||
// Poll reports whether accountID has been matched since it queued, returning the
|
||||
// started game once (the result is drained on read). It reports Matched=false
|
||||
// while the account is still waiting or has no pending result.
|
||||
func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if g, ok := m.results[accountID]; ok {
|
||||
delete(m.results, accountID)
|
||||
return EnqueueResult{Matched: true, Game: g}, nil
|
||||
if joined {
|
||||
m.announceOpponent(ctx, g, accountID)
|
||||
}
|
||||
return EnqueueResult{}, nil
|
||||
return EnqueueResult{Matched: joined, Game: g}, nil
|
||||
}
|
||||
|
||||
// Cancel removes accountID from whatever pool it waits in and drops any pending
|
||||
// matched result, reporting whether it was queued. Clearing the result closes the
|
||||
// race where the reaper substituted a robot just before the player cancelled: the
|
||||
// stale game must not later surface through Poll as a game the player did not want.
|
||||
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.results, accountID)
|
||||
key, ok := m.queued[accountID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
m.removeLocked(accountID, key)
|
||||
return true
|
||||
}
|
||||
|
||||
// QueueLen returns the number of accounts waiting in the variant pool, summed across
|
||||
// both per-turn word rules.
|
||||
func (m *Matchmaker) QueueLen(variant engine.Variant) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.queues[matchKey{variant: variant, multipleWords: false}]) +
|
||||
len(m.queues[matchKey{variant: variant, multipleWords: true}])
|
||||
}
|
||||
|
||||
// RunReaper substitutes a robot for any player that has waited past waitDelay,
|
||||
// scanning every interval until ctx is cancelled. It is started once from main.
|
||||
// RunReaper substitutes a robot for any open game past its wait window, scanning every
|
||||
// interval until ctx is cancelled. It is started once from main.
|
||||
func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -203,77 +111,83 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reap pairs every player that has waited past waitDelay with a freshly picked
|
||||
// robot and starts the game, recording it for the player's Poll. RunReaper calls
|
||||
// it on a timer; it takes now explicitly so tests and ops can drive a single pass
|
||||
// at a chosen instant. A waiter is only dequeued once a robot is secured, so a
|
||||
// momentarily empty pool just defers substitution to a later tick.
|
||||
// Reap substitutes a robot into every open game whose wait window elapsed by now and
|
||||
// pushes opponent_joined to its starter. RunReaper calls it on a timer; it takes now
|
||||
// explicitly so tests and ops can drive a single pass at a chosen instant. A game for
|
||||
// which no robot is available is left for a later tick.
|
||||
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
||||
type sub struct {
|
||||
human uuid.UUID
|
||||
key matchKey
|
||||
seats []uuid.UUID
|
||||
due, err := m.games.ExpiredOpen(ctx, now)
|
||||
if err != nil {
|
||||
m.log.Warn("scan open games", zap.Error(err))
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
var due []uuid.UUID
|
||||
for acc, since := range m.waitingSince {
|
||||
if now.Sub(since) >= m.waitDelay {
|
||||
due = append(due, acc)
|
||||
}
|
||||
}
|
||||
var subs []sub
|
||||
for _, acc := range due {
|
||||
key := m.queued[acc]
|
||||
robotID, err := m.robots.Pick(key.variant)
|
||||
for _, og := range due {
|
||||
robotID, err := m.robots.Pick(og.Variant)
|
||||
if err != nil {
|
||||
m.log.Warn("robot substitution deferred", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
m.removeLocked(acc, key)
|
||||
seats := []uuid.UUID{acc, robotID}
|
||||
if m.rng.Intn(2) == 0 {
|
||||
seats[0], seats[1] = seats[1], seats[0]
|
||||
}
|
||||
subs = append(subs, sub{human: acc, key: key, seats: seats})
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, s := range subs {
|
||||
g, err := m.games.Create(ctx, autoMatchParams(s.key, s.seats))
|
||||
g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID)
|
||||
if err != nil {
|
||||
m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err))
|
||||
m.log.Warn("robot substitution failed", zap.String("game", og.ID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.results[s.human] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(ctx, g)
|
||||
if !attached {
|
||||
continue // a human joined first between the scan and the substitution
|
||||
}
|
||||
m.announceOpponent(ctx, g, robotID)
|
||||
}
|
||||
}
|
||||
|
||||
// removeLocked drops accountID from the queue, the queued index and the waiting
|
||||
// clock. The caller holds m.mu.
|
||||
func (m *Matchmaker) removeLocked(accountID uuid.UUID, key matchKey) {
|
||||
delete(m.queued, accountID)
|
||||
delete(m.waitingSince, accountID)
|
||||
q := m.queues[key]
|
||||
for i, id := range q {
|
||||
if id == accountID {
|
||||
m.queues[key] = append(q[:i], q[i+1:]...)
|
||||
break
|
||||
// announceOpponent pushes opponent_joined to the game's waiting starter — the seat
|
||||
// that is not joinerID — so its client fills the opponent card and re-enables resign
|
||||
// and chat in place. Routed by the game's language, like every game push.
|
||||
func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID uuid.UUID) {
|
||||
starter, ok := otherSeat(g, joinerID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := m.games.InitialState(ctx, g.ID, starter)
|
||||
if err != nil {
|
||||
m.log.Warn("opponent_joined initial state",
|
||||
zap.String("game", g.ID.String()), zap.String("account", starter.String()), zap.Error(err))
|
||||
return
|
||||
}
|
||||
intent := notify.OpponentJoined(starter, g.ID, state)
|
||||
intent.Language = g.Variant.Language()
|
||||
m.pub.Publish(intent)
|
||||
}
|
||||
|
||||
// openDeadline is when the reaper substitutes a robot for a game opened now: a fixed
|
||||
// minimum wait plus a random jitter, so the substitution time varies per game.
|
||||
func (m *Matchmaker) openDeadline() time.Time {
|
||||
d := m.minWait
|
||||
if m.jitter > 0 {
|
||||
d += rand.N(m.jitter)
|
||||
}
|
||||
return m.clock().Add(d)
|
||||
}
|
||||
|
||||
// otherSeat returns the account at the seat that is not accountID — the open game's
|
||||
// starter when accountID is the joiner — and false when no seat differs or it is still
|
||||
// empty.
|
||||
func otherSeat(g game.Game, accountID uuid.UUID) (uuid.UUID, bool) {
|
||||
for _, s := range g.Seats {
|
||||
if s.AccountID != accountID && s.AccountID != uuid.Nil {
|
||||
return s.AccountID, true
|
||||
}
|
||||
}
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
// autoMatchParams builds the create parameters for a two-player auto-match with
|
||||
// the casual defaults.
|
||||
func autoMatchParams(key matchKey, seats []uuid.UUID) game.CreateParams {
|
||||
// autoMatchParams builds the create parameters for a two-player auto-match with the
|
||||
// casual defaults; the game service assembles the seats and pins the bag seed.
|
||||
func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
|
||||
return game.CreateParams{
|
||||
Variant: key.variant,
|
||||
Seats: seats,
|
||||
Variant: variant,
|
||||
TurnTimeout: game.DefaultTurnTimeout,
|
||||
HintsAllowed: autoMatchHintsAllowed,
|
||||
HintsPerPlayer: autoMatchHintsPerPlayer,
|
||||
MultipleWordsPerTurn: key.multipleWords,
|
||||
MultipleWordsPerTurn: multipleWords,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,28 +14,51 @@ import (
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// fakeCreator records the games a matchmaker asks it to start.
|
||||
type fakeCreator struct {
|
||||
created []game.CreateParams
|
||||
err error
|
||||
// stubMatcher is a fake GameMatcher: it returns canned games and records the calls the
|
||||
// matchmaker makes, so the unit tests cover delegation, the opponent_joined emit and
|
||||
// the wait-window math without a database. The DB-backed open/join/substitute logic is
|
||||
// covered by the integration suite.
|
||||
type stubMatcher struct {
|
||||
openGame game.Game
|
||||
openJoined bool
|
||||
openErr error
|
||||
openCalls int
|
||||
lastDeadline time.Time
|
||||
|
||||
expired []game.OpenGame
|
||||
|
||||
attachGame game.Game
|
||||
attached bool
|
||||
attachErr error
|
||||
attachedGames []uuid.UUID
|
||||
}
|
||||
|
||||
func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) {
|
||||
if f.err != nil {
|
||||
return game.Game{}, f.err
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
|
||||
s.openCalls++
|
||||
s.lastDeadline = deadline
|
||||
return s.openGame, s.openJoined, s.openErr
|
||||
}
|
||||
|
||||
func (s *stubMatcher) AttachRobot(_ context.Context, gameID, _ uuid.UUID) (game.Game, bool, error) {
|
||||
if s.attachErr != nil {
|
||||
return game.Game{}, false, s.attachErr
|
||||
}
|
||||
f.created = append(f.created, p)
|
||||
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
||||
if s.attached {
|
||||
s.attachedGames = append(s.attachedGames, gameID)
|
||||
}
|
||||
return s.attachGame, s.attached, nil
|
||||
}
|
||||
|
||||
// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing
|
||||
// tests assert on matching behaviour, not the payload, so an empty state is enough.
|
||||
func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
|
||||
func (s *stubMatcher) ExpiredOpen(_ context.Context, _ time.Time) ([]game.OpenGame, error) {
|
||||
return s.expired, nil
|
||||
}
|
||||
|
||||
func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
|
||||
return notify.PlayerState{}, nil
|
||||
}
|
||||
|
||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
|
||||
// an empty pool. It records the variant of the last substitution request.
|
||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an
|
||||
// empty pool. It records the variant of the last substitution request.
|
||||
type fakeRobots struct {
|
||||
id uuid.UUID
|
||||
err error
|
||||
@@ -50,294 +73,137 @@ func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
|
||||
return f.id, nil
|
||||
}
|
||||
|
||||
// testWaitDelay is long enough that the reaper never fires in the pairing tests
|
||||
// (which do not run it); the substitution tests drive reap directly.
|
||||
const testWaitDelay = 10 * time.Second
|
||||
// capturePub records every published intent.
|
||||
type capturePub struct{ intents []notify.Intent }
|
||||
|
||||
func newTestMatchmaker(creator GameCreator, robotID uuid.UUID) *Matchmaker {
|
||||
return NewMatchmaker(creator, &fakeRobots{id: robotID}, testWaitDelay, zap.NewNop())
|
||||
}
|
||||
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
|
||||
|
||||
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
|
||||
for _, w := range want {
|
||||
found := false
|
||||
for _, s := range seats {
|
||||
if s == w {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
|
||||
// (uuid.Nil for a still-empty opponent seat).
|
||||
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
||||
return game.Game{
|
||||
ID: uuid.New(),
|
||||
Variant: engine.VariantEnglish,
|
||||
Seats: []game.Seat{
|
||||
{Seat: 0, AccountID: starter},
|
||||
{Seat: 1, AccountID: opponent},
|
||||
},
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestMatchmakerPairsTwoHumans(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
a, b := uuid.New(), uuid.New()
|
||||
func TestEnqueueOpensGameWithoutOpponent(t *testing.T) {
|
||||
starter := uuid.New()
|
||||
m := &stubMatcher{openGame: twoSeatGame(starter, uuid.Nil)}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
|
||||
res, err := mm.Enqueue(context.Background(), starter, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must wait, not match")
|
||||
if res.Matched {
|
||||
t.Error("opening a game must report Matched=false")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
|
||||
if m.openCalls != 1 {
|
||||
t.Errorf("OpenOrJoin calls = %d, want 1", m.openCalls)
|
||||
}
|
||||
if len(pub.intents) != 0 {
|
||||
t.Errorf("opening a game must not emit opponent_joined; got %d intents", len(pub.intents))
|
||||
}
|
||||
}
|
||||
|
||||
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
|
||||
func TestEnqueueJoinEmitsOpponentJoinedToStarter(t *testing.T) {
|
||||
starter, joiner := uuid.New(), uuid.New()
|
||||
m := &stubMatcher{openGame: twoSeatGame(starter, joiner), openJoined: true}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
res, err := mm.Enqueue(context.Background(), joiner, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if !r2.Matched {
|
||||
t.Fatal("second enqueue must match")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Fatalf("queue len = %d, want 0 after match", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
p := creator.created[0]
|
||||
if len(p.Seats) != 2 || !seatsContain(p.Seats, a, b) {
|
||||
t.Errorf("seats = %v, want both %s and %s", p.Seats, a, b)
|
||||
}
|
||||
if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer {
|
||||
t.Errorf("auto-match defaults not applied: %+v", p)
|
||||
}
|
||||
|
||||
// The waiting opponent learns of the match through Poll, exactly once.
|
||||
got, err := mm.Poll(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("poll a: %v", err)
|
||||
}
|
||||
if !got.Matched || got.Game.ID != r2.Game.ID {
|
||||
t.Errorf("poll a = %+v, want the matched game %s", got, r2.Game.ID)
|
||||
}
|
||||
if again, _ := mm.Poll(ctx, a); again.Matched {
|
||||
t.Error("poll result must drain after the first read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerAlreadyQueued(t *testing.T) {
|
||||
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); !errors.Is(err, ErrAlreadyQueued) {
|
||||
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
|
||||
if !res.Matched {
|
||||
t.Error("joining a waiting game must report Matched=true")
|
||||
}
|
||||
if len(pub.intents) != 1 {
|
||||
t.Fatalf("joining must emit one opponent_joined; got %d", len(pub.intents))
|
||||
}
|
||||
if got := pub.intents[0]; got.Kind != notify.KindOpponentJoined || got.UserID != starter {
|
||||
t.Errorf("opponent_joined = (kind %q, user %s), want (%q, starter %s)", got.Kind, got.UserID, notify.KindOpponentJoined, starter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerCancel(t *testing.T) {
|
||||
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if !mm.Cancel(ctx, a) {
|
||||
t.Fatal("cancel of a queued account must report true")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Fatalf("queue len = %d, want 0 after cancel", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
if mm.Cancel(ctx, a) {
|
||||
t.Fatal("cancel of an unqueued account must report false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerVariantsAreSeparate(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue en: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil {
|
||||
t.Fatalf("enqueue ru: %v", err)
|
||||
}
|
||||
if len(creator.created) != 0 {
|
||||
t.Fatalf("different variants must not match; created %d", len(creator.created))
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 || mm.QueueLen(engine.VariantRussianScrabble) != 1 {
|
||||
t.Errorf("each variant pool should hold one waiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerFIFO(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
||||
for _, id := range []uuid.UUID{a, b, c} {
|
||||
if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// a waited, b matched a (oldest), c waits.
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
if !seatsContain(creator.created[0].Seats, a, b) {
|
||||
t.Errorf("FIFO match should pair a and b, got %v", creator.created[0].Seats)
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperSubstitutesRobot(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
robotID := uuid.New()
|
||||
mm := newTestMatchmaker(creator, robotID)
|
||||
func TestEnqueueDeadlineWithinWindow(t *testing.T) {
|
||||
base := time.Now()
|
||||
m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, 90*time.Second, 90*time.Second, zap.NewNop())
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
if _, err := mm.Enqueue(context.Background(), uuid.New(), engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
|
||||
mm.Reap(ctx, base.Add(5*time.Second)) // before the wait window
|
||||
if len(creator.created) != 0 || mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Fatalf("must not substitute before the wait: created=%d queued=%d", len(creator.created), mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // past the wait window
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1 after substitution", len(creator.created))
|
||||
}
|
||||
if !seatsContain(creator.created[0].Seats, a, robotID) {
|
||||
t.Errorf("substituted game seats = %v, want human %s and robot %s", creator.created[0].Seats, a, robotID)
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Errorf("waiter should be dequeued after substitution")
|
||||
}
|
||||
got, err := mm.Poll(ctx, a)
|
||||
if err != nil || !got.Matched {
|
||||
t.Errorf("poll after substitution = %+v err=%v, want matched", got, err)
|
||||
lo, hi := base.Add(90*time.Second), base.Add(180*time.Second)
|
||||
if m.lastDeadline.Before(lo) || !m.lastDeadline.Before(hi) {
|
||||
t.Errorf("deadline %s not in [%s, %s)", m.lastDeadline, lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
func TestReapSubstitutesRobotAndEmits(t *testing.T) {
|
||||
human, robotID := uuid.New(), uuid.New()
|
||||
og := game.OpenGame{ID: uuid.New(), Variant: engine.VariantRussianScrabble}
|
||||
m := &stubMatcher{
|
||||
expired: []game.OpenGame{og},
|
||||
attachGame: twoSeatGame(human, robotID),
|
||||
attached: true,
|
||||
}
|
||||
mm.Cancel(ctx, a)
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
||||
if len(creator.created) != 0 {
|
||||
t.Errorf("a cancelled waiter must not be substituted; created %d", len(creator.created))
|
||||
robots := &fakeRobots{id: robotID}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
mm.Reap(context.Background(), time.Now())
|
||||
|
||||
if robots.lastVariant != engine.VariantRussianScrabble {
|
||||
t.Errorf("robot picked for %v, want the open game's variant", robots.lastVariant)
|
||||
}
|
||||
if len(m.attachedGames) != 1 || m.attachedGames[0] != og.ID {
|
||||
t.Errorf("attached games = %v, want [%s]", m.attachedGames, og.ID)
|
||||
}
|
||||
if len(pub.intents) != 1 || pub.intents[0].Kind != notify.KindOpponentJoined || pub.intents[0].UserID != human {
|
||||
t.Errorf("reap must emit opponent_joined to the human starter; got %+v", pub.intents)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
|
||||
// robot just before the player cancels: Cancel must drop the pending result so the
|
||||
// abandoned game never surfaces through Poll.
|
||||
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
func TestReapDefersWithoutRobot(t *testing.T) {
|
||||
m := &stubMatcher{expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, &fakeRobots{err: errors.New("empty pool")}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
mm.Reap(context.Background(), time.Now())
|
||||
|
||||
if len(m.attachedGames) != 0 {
|
||||
t.Errorf("no robot available: must not attach; attached %v", m.attachedGames)
|
||||
}
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
|
||||
mm.Cancel(ctx, a) // ... then the player cancels
|
||||
if got, _ := mm.Poll(ctx, a); got.Matched {
|
||||
t.Error("cancel must drop the pending substituted game; Poll still matched")
|
||||
if len(pub.intents) != 0 {
|
||||
t.Errorf("no robot available: must not emit; got %d intents", len(pub.intents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
func TestReapSkipsWhenHumanJoinedFirst(t *testing.T) {
|
||||
m := &stubMatcher{
|
||||
expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}},
|
||||
attached: false, // AttachRobot reports the game already filled by a human
|
||||
}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
||||
if len(creator.created) != 0 {
|
||||
t.Errorf("no robot available: must not create a game; created %d", len(creator.created))
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakerRulesAreSeparate confirms two players who chose the same variant but a
|
||||
// different per-turn word rule are not paired, and that the rule reaches the started game.
|
||||
func TestMatchmakerRulesAreSeparate(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
|
||||
// Same variant, opposite rules: they must not match.
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil {
|
||||
t.Fatalf("enqueue single-word: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil {
|
||||
t.Fatalf("enqueue standard: %v", err)
|
||||
}
|
||||
if len(creator.created) != 0 {
|
||||
t.Fatalf("different rules must not match; created %d", len(creator.created))
|
||||
}
|
||||
|
||||
// A second single-word player pairs with the first; the game carries the rule.
|
||||
r, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue single-word opponent: %v", err)
|
||||
}
|
||||
if !r.Matched {
|
||||
t.Fatal("same variant and rule must match")
|
||||
}
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
if creator.created[0].MultipleWordsPerTurn {
|
||||
t.Error("single-word match must create a game with MultipleWordsPerTurn=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakerReaperKeepsRule confirms a robot substitution carries the waiter's rule.
|
||||
func TestMatchmakerReaperKeepsRule(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
if creator.created[0].MultipleWordsPerTurn {
|
||||
t.Error("robot substitution must keep the waiter's single-word rule")
|
||||
mm.Reap(context.Background(), time.Now())
|
||||
|
||||
if len(pub.intents) != 0 {
|
||||
t.Errorf("a human-filled game must not emit opponent_joined; got %d", len(pub.intents))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user