feat(lobby): enter the game immediately and wait for the opponent inside it #51

Merged
developer merged 4 commits from feature/quick-game-open-wait into development 2026-06-13 09:14:51 +00:00
42 changed files with 1248 additions and 768 deletions
Showing only changes of commit c305363ccd - Show all commits
+9
View File
@@ -26,6 +26,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| R7 | Final stress run + tuning | 9b | **done** | | R7 | Final stress run + tuning | 9b | **done** |
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** | | MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## Key findings (these reshaped the raw list — read before starting a phase)
@@ -69,6 +70,14 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** - **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag**
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold, a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
**no auto-ban**. **no auto-ban**.
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
joins it, or a robot fills it after a **90 s + random 090 s** wait, pushing the in-app
**opponent_joined** event. While open, the starter may move on their turn but resign, chat and
nudge are disabled, and the lobby + opponent card read "searching for opponent". Matchmaking is
now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
empty seat.
## Phases ## Phases
+11 -9
View File
@@ -28,9 +28,10 @@ export, per-account statistics on finish, and a background turn-timeout sweeper
that auto-resigns overdue turns (honouring each player's daily away window). Like that auto-resigns overdue turns (honouring each player's daily away window). Like
the engine it is a service/store layer; the HTTP surface lives in the `gateway`. the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
The lobby and social fabric. `internal/lobby` holds an in-memory The lobby and social fabric. `internal/lobby` runs **auto-match**`Enqueue` opens a
matchmaking pool (FIFO per variant and per-turn word rule, pairs two humans into an real game seating the caller with an **empty opponent seat** (status `open`) or, when
auto-match) and another player already waits for the same variant and per-turn word rule, seats the
caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 player game once every friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept), invitee accepts). `internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat per-user blocks, and per-game chat with nudges folded in as a message kind; chat
@@ -52,16 +53,17 @@ robot's moves through the public game API as an ordinary seated player (so only
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra behaviour — all derived deterministically from the game seed, so it keeps no extra
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and state. A background **reaper** seats a pooled robot (matching the game's language) in any open
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down game whose wait window — a fixed **90 s** plus a random **090 s** (so **90180 s**) — has
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's elapsed, and the waiting starter is told an opponent took the seat by an in-app
initial game state) drives it instead. **opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
re-enables resign and chat in place.
The backend opens to the edge. The route groups gain their first The backend opens to the edge. The route groups gain their first
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a `/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
slice of authenticated `/api/v1/user` operations (profile, submit play, game slice of authenticated `/api/v1/user` operations (profile, submit play, game
state, lobby enqueue/poll, chat). The social/account/history operations under state, lobby enqueue, chat). The social/account/history operations under
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*` list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`, (create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
@@ -126,7 +128,7 @@ internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts) internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
+4 -2
View File
@@ -170,12 +170,14 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
go robots.Run(ctx, cfg.Robot.DriveInterval) go robots.Run(ctx, cfg.Robot.DriveInterval)
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval)) logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger) matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
matchmaker.SetNotifier(hub) matchmaker.SetNotifier(hub)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
invitations.SetNotifier(hub) invitations.SetNotifier(hub)
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait)) logger.Info("lobby and social domains ready",
zap.Duration("robot_wait", cfg.Lobby.RobotWait),
zap.Duration("robot_wait_jitter", cfg.Lobby.RobotWaitJitter))
// Rate-limit observability: ingest the gateway's rejection reports for the // Rate-limit observability: ingest the gateway's rejection reports for the
// admin throttled view and the conservative high-rate auto-flag. // admin throttled view and the conservative high-rate auto-flag.
+3
View File
@@ -100,6 +100,9 @@ func Load() (Config, error) {
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil { if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
return Config{}, err 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 { if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
return Config{}, err return Config{}, err
} }
+9 -1
View File
@@ -1,6 +1,8 @@
package game package game
import ( import (
"github.com/google/uuid"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
) )
@@ -21,9 +23,15 @@ func gameSummary(g Game, names []string) notify.GameSummary {
if s.Seat >= 0 && s.Seat < len(names) { if s.Seat >= 0 && s.Seat < len(names) {
name = names[s.Seat] 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{ seats = append(seats, notify.SeatStanding{
Seat: s.Seat, Seat: s.Seat,
AccountID: s.AccountID.String(), AccountID: accountID,
DisplayName: name, DisplayName: name,
Score: s.Score, Score: s.Score,
HintsUsed: s.HintsUsed, HintsUsed: s.HintsUsed,
+108 -4
View File
@@ -145,6 +145,96 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
return svc.store.GetGame(ctx, id) 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 // engineOp applies one transition to the live game, returning the decoded record
// and, for an exchange, the swapped tiles. // and, for an exchange, the swapped tiles.
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error) 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 { if !ok {
return MoveResult{}, ErrNotAPlayer 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 { if pre.Status != StatusActive {
return MoveResult{}, ErrFinished return MoveResult{}, ErrFinished
} }
@@ -260,7 +355,10 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if !ok { if !ok {
return MoveResult{}, ErrNotAPlayer 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 return MoveResult{}, ErrFinished
} }
if pre.ToMove != seat { if pre.ToMove != seat {
@@ -382,6 +480,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
summary := gameSummary(post, names) summary := gameSummary(post, names)
intents := make([]notify.Intent, 0, 2*len(post.Seats)) intents := make([]notify.Intent, 0, 2*len(post.Seats))
for _, s := range 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)) 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 // 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 // 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). // an out-of-app "game over" push (online players take it from the in-app refresh).
for _, s := range post.Seats { 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 := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
over.Language = lang over.Language = lang
intents = append(intents, over) 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 { if _, ok := pre.seatOf(accountID); !ok {
return EvalResult{}, ErrNotAPlayer return EvalResult{}, ErrNotAPlayer
} }
if pre.Status != StatusActive { if pre.Status == StatusFinished {
return EvalResult{}, ErrFinished return EvalResult{}, ErrFinished
} }
@@ -674,7 +778,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
if !ok { if !ok {
return HintResult{}, ErrNotAPlayer return HintResult{}, ErrNotAPlayer
} }
if pre.Status != StatusActive { if pre.Status == StatusFinished {
return HintResult{}, ErrFinished return HintResult{}, ErrFinished
} }
if pre.ToMove != seat { if pre.ToMove != seat {
@@ -736,7 +840,7 @@ func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID)
if !ok { if !ok {
return nil, ErrNotAPlayer return nil, ErrNotAPlayer
} }
if pre.Status != StatusActive { if pre.Status == StatusFinished {
return nil, ErrFinished return nil, ErrFinished
} }
if pre.ToMove != seat { if pre.ToMove != seat {
+196 -15
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"hash/fnv"
"time" "time"
"github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/postgres"
@@ -40,6 +41,13 @@ type gameInsert struct {
dropoutTiles string dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game. // multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn bool 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. // 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. // first) inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error { func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error { return withTx(ctx, s.db, func(tx *sql.Tx) error {
gi := table.Games.INSERT( return insertGameTx(ctx, tx, ins, seats)
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) // insertGameTx inserts the games row and one game_players row per seat (seat 0
if _, err := gi.ExecContext(ctx, tx); err != nil { // first) on tx. A seat whose account id is uuid.Nil is written with a NULL
return fmt.Errorf("insert game: %w", err) // 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(
pi := table.GamePlayers.INSERT( table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID, ).VALUES(ins.id, seat, acc)
).VALUES(ins.id, seat, accountID) if _, err := pi.ExecContext(ctx, tx); err != nil {
if _, err := pi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert seat %d: %w", seat, err)
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 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 // 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)) out := make([]RobotTurn, 0, len(rows))
for _, r := range 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{ out = append(out, RobotTurn{
GameID: r.Games.GameID, GameID: r.Games.GameID,
RobotID: r.GamePlayers.AccountID, RobotID: robotID,
RobotSeat: int(r.GamePlayers.Seat), RobotSeat: int(r.GamePlayers.Seat),
ToMove: int(r.Games.ToMove), ToMove: int(r.Games.ToMove),
TurnStartedAt: r.Games.TurnStartedAt, 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)) out.Seats = make([]Seat, 0, len(seats))
for _, p := range 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{ out.Seats = append(out.Seats, Seat{
Seat: int(p.Seat), Seat: int(p.Seat),
AccountID: p.AccountID, AccountID: accountID,
Score: int(p.Score), Score: int(p.Score),
HintsUsed: int(p.HintsUsed), HintsUsed: int(p.HintsUsed),
IsWinner: p.IsWinner, IsWinner: p.IsWinner,
+18 -1
View File
@@ -13,11 +13,16 @@ import (
const ( const (
StatusActive = "active" StatusActive = "active"
StatusFinished = "finished" 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 // Complaint lifecycle values. A complaint is filed StatusComplaintOpen
// and closed StatusComplaintResolved by the admin review queue with a // 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 ( const (
StatusComplaintOpen = "open" StatusComplaintOpen = "open"
StatusComplaintResolved = "resolved" StatusComplaintResolved = "resolved"
@@ -43,6 +48,10 @@ var (
ErrNotYourTurn = errors.New("game: not the player's turn") ErrNotYourTurn = errors.New("game: not the player's turn")
// ErrFinished is returned when a transition is attempted on a finished game. // ErrFinished is returned when a transition is attempted on a finished game.
ErrFinished = errors.New("game: game is finished") 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 // 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. // (such as a GCG export) is attempted while the game is still active.
ErrGameActive = errors.New("game: game is still active") ErrGameActive = errors.New("game: game is still active")
@@ -117,6 +126,14 @@ type Seat struct {
IsWinner bool 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 // seatOf returns the seat index of accountID and true, or (0, false) when the
// account is not seated. // account is not seated.
func (g Game) seatOf(accountID uuid.UUID) (int, bool) { func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
+14 -4
View File
@@ -56,11 +56,21 @@ func newRobotService(t *testing.T, games *game.Service) *robot.Service {
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop()) 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 // newMatchmaker builds a matchmaker opening real games and substituting from robots
// robots after wait. // after minWait plus a random jitter in [0, jitter).
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker { func newMatchmaker(t *testing.T, robots lobby.RobotProvider, minWait, jitter time.Duration) *lobby.Matchmaker {
t.Helper() 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. // provisionAccount creates a fresh durable account and returns its id.
+43 -7
View File
@@ -30,31 +30,67 @@ func englishInvite() lobby.InvitationSettings {
} }
} }
func TestMatchmakingPairsAndStartsGame(t *testing.T) { func TestMatchmakingOpensThenJoins(t *testing.T) {
ctx := context.Background() 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) 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) r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil { if err != nil {
t.Fatalf("enqueue a: %v", err) t.Fatalf("enqueue a: %v", err)
} }
if r1.Matched { 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) r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil { if err != nil {
t.Fatalf("enqueue b: %v", err) t.Fatalf("enqueue b: %v", err)
} }
if !r2.Matched { if !r2.Matched || r2.Game.ID != r1.Game.ID {
t.Fatal("second enqueue must match") 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) seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID)
if err != nil { if err != nil {
t.Fatalf("participants: %v", err) t.Fatalf("participants: %v", err)
} }
if status != "active" || len(seats) != 2 { has := func(id uuid.UUID) bool {
t.Fatalf("matched game state: status %q seats %v", status, seats) for _, s := range seats {
if s == id {
return true
}
}
return false
}
if status != "active" || len(seats) != 2 || !has(a) || !has(b) {
t.Fatalf("joined game: status %q seats %v (want active with a=%s and b=%s)", status, seats, a, b)
}
}
// TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the
// caller gets their existing open game rather than a second one.
func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
a := provisionAccount(t)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("re-enqueue: %v", err)
}
if r2.Game.ID != r1.Game.ID || r2.Matched {
t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID)
} }
} }
+254
View File
@@ -0,0 +1,254 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social"
)
// The open-game suite covers an auto-match game that a player enters immediately and
// waits inside (status 'open', the opponent seat empty) until a human or a robot joins.
// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
// moves first) whose fresh two-player English opening rack has a legal move.
func evenOpeningSeed(t *testing.T) int64 {
t.Helper()
for seed := int64(2); seed <= 400; seed += 2 {
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
if err != nil {
t.Fatalf("engine new: %v", err)
}
if _, ok := g.HintView(); ok {
return seed
}
}
t.Fatal("no even opening seed found")
return 0
}
// openParams are the casual auto-match settings with a pinned seed.
func openParams(seed int64) game.CreateParams {
return game.CreateParams{
Variant: engine.VariantEnglish,
TurnTimeout: 24 * time.Hour,
HintsAllowed: true,
HintsPerPlayer: 1,
MultipleWordsPerTurn: true,
Seed: seed,
}
}
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
t.Helper()
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("open game: %v", err)
}
if joined || g.Status != game.StatusOpen {
t.Fatalf("opened game = (joined %v, status %q), want (false, open)", joined, g.Status)
}
return g
}
// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
// while the game is open, after which it is the empty opponent seat's turn and the
// starter just waits.
func TestOpenGameStarterMovesThenWaits(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
seed := evenOpeningSeed(t)
g := openGame(t, svc, provisionAccount(t), seed)
starter := g.Seats[0].AccountID
hint, ok := newMirror(t, seed, 2).HintView()
if !ok || len(hint.Tiles) == 0 {
t.Fatal("no opening move for the seed")
}
res, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles)
if err != nil {
t.Fatalf("starter play while open: %v", err)
}
if res.Game.Status != game.StatusOpen {
t.Errorf("after the starter's move the game must stay open, got %q", res.Game.Status)
}
if res.Game.ToMove != 1 {
t.Errorf("after the starter's move it must be the empty seat's turn (to_move 1), got %d", res.Game.ToMove)
}
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
t.Fatalf("starter acting on the opponent's turn = %v, want ErrNotYourTurn", err)
}
}
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("open: %v", err)
}
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
}
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err)
}
}
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after
// the starter has already made their first move, landing on the board mid-opening and
// able to act on their turn.
func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
seed := evenOpeningSeed(t)
g := openGame(t, svc, provisionAccount(t), seed)
starter := g.Seats[0].AccountID
hint, ok := newMirror(t, seed, 2).HintView()
if !ok {
t.Fatal("no opening move")
}
if _, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles); err != nil {
t.Fatalf("starter play: %v", err)
}
joiner := provisionAccount(t)
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
if err != nil {
t.Fatalf("join: %v", err)
}
if !joined || g2.ID != g.ID {
t.Fatalf("join = (joined %v, game %s), want it to join %s", joined, g2.ID, g.ID)
}
if g2.Status != game.StatusActive || g2.MoveCount != 1 || g2.ToMove != 1 {
t.Fatalf("joined game = (status %q, moves %d, to_move %d), want (active, 1, 1)", g2.Status, g2.MoveCount, g2.ToMove)
}
// It is the joiner's turn (seat 1); they can act.
if _, err := svc.Pass(ctx, g.ID, joiner); err != nil {
t.Fatalf("joiner pass on their turn: %v", err)
}
}
// TestOpenGameResignRejectedUntilOpponent checks resign is refused while the game is
// open and allowed once an opponent (a robot here) has joined.
func TestOpenGameResignRejectedUntilOpponent(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
starter := g.Seats[0].AccountID
if _, err := svc.Resign(ctx, g.ID, starter); !errors.Is(err, game.ErrNoOpponentYet) {
t.Fatalf("resign while open = %v, want ErrNoOpponentYet", err)
}
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
if _, attached, err := svc.AttachRobot(ctx, g.ID, robotID); err != nil || !attached {
t.Fatalf("attach robot = (attached %v, err %v), want attached", attached, err)
}
res, err := svc.Resign(ctx, g.ID, starter)
if err != nil {
t.Fatalf("resign after the opponent joined: %v", err)
}
if res.Game.Status != game.StatusFinished {
t.Errorf("resign must finish the game, got %q", res.Game.Status)
}
}
// TestOpenGameChatAndNudgeRejected checks chat and nudge are refused while the game is
// open (no opponent to converse with).
func TestOpenGameChatAndNudgeRejected(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
soc := newSocialService()
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
starter := g.Seats[0].AccountID
if _, err := soc.PostMessage(ctx, g.ID, starter, "hello", ""); !errors.Is(err, social.ErrGameNotActive) {
t.Errorf("chat while open = %v, want ErrGameNotActive", err)
}
if _, err := soc.Nudge(ctx, g.ID, starter); !errors.Is(err, social.ErrGameNotActive) {
t.Errorf("nudge while open = %v, want ErrGameNotActive", err)
}
}
// TestOpenGameSweeperSkips checks the turn-timeout sweeper never finishes an open game,
// even with a long-stale turn clock (its seat is the empty opponent's).
func TestOpenGameSweeperSkips(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
setTurnStarted(t, g.ID, time.Now().Add(-1000*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
g2, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if g2.Status != game.StatusOpen {
t.Errorf("open game must survive the sweeper, got %q", g2.Status)
}
}
// TestOpenGameLobbyShowsEmptySeat checks an open game appears in the starter's lobby
// with two seats, one of them the still-empty opponent (uuid.Nil).
func TestOpenGameLobbyShowsEmptySeat(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g := openGame(t, svc, starter, evenOpeningSeed(t))
games, err := svc.ListForAccount(ctx, starter)
if err != nil {
t.Fatalf("list for account: %v", err)
}
var found *game.Game
for i := range games {
if games[i].ID == g.ID {
found = &games[i]
break
}
}
if found == nil {
t.Fatal("open game must appear in the starter's lobby")
}
var hasStarter, hasEmpty bool
for _, s := range found.Seats {
switch s.AccountID {
case starter:
hasStarter = true
case uuid.Nil:
hasEmpty = true
}
}
if len(found.Seats) != 2 || !hasStarter || !hasEmpty {
t.Errorf("open game seats = %+v, want the starter and one empty (nil) seat", found.Seats)
}
}
+8 -15
View File
@@ -138,36 +138,29 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
} }
} }
// TestMatchmakerSubstitutesRobotEndToEnd checks a waiting human is paired with a // TestMatchmakerSubstitutesRobotEndToEnd checks the reaper fills an open game's empty
// real robot account after the wait window, discoverable through Poll. // seat with a real robot account once its wait window has elapsed.
func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t)
robots := newRobotService(t, newGameService()) robots := newRobotService(t, newGameService())
if err := robots.EnsurePool(ctx); err != nil { if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err) 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) human := provisionAccount(t)
before := time.Now()
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true) r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
if err != nil { if err != nil {
t.Fatalf("enqueue: %v", err) t.Fatalf("enqueue: %v", err)
} }
if r.Matched { 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)) mm.Reap(ctx, time.Now().Add(time.Second)) // past the (zero) wait window
got, err := mm.Poll(ctx, human) seats, _, status, err := newGameService().Participants(ctx, r.Game.ID)
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)
if err != nil { if err != nil {
t.Fatalf("participants: %v", err) t.Fatalf("participants: %v", err)
} }
+20 -9
View File
@@ -5,22 +5,30 @@ import (
"time" "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 { type Config struct {
// RobotWait is how long an auto-match player waits for a human before a robot // RobotWait is the fixed minimum an open auto-match game waits for a human
// is substituted. Sourced from BACKEND_LOBBY_ROBOT_WAIT. // opponent before it is eligible for robot substitution. Sourced from
// BACKEND_LOBBY_ROBOT_WAIT.
RobotWait time.Duration RobotWait time.Duration
// ReaperInterval is how often the substitution reaper scans for over-waited // RobotWaitJitter is a random extra wait in [0, RobotWaitJitter) added on top of
// players. Sourced from BACKEND_LOBBY_REAPER_INTERVAL. // 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 ReaperInterval time.Duration
} }
// DefaultConfig returns the matchmaking defaults: a 10-second wait // DefaultConfig returns the matchmaking defaults: a guaranteed 90-second wait for a
// (docs/ARCHITECTURE.md §7) scanned every second. // human plus up to 90 random seconds (90180 s total) before a robot substitutes
// (docs/ARCHITECTURE.md §7), scanned every five seconds.
func DefaultConfig() Config { func DefaultConfig() Config {
return Config{ return Config{
RobotWait: 10 * time.Second, RobotWait: 90 * time.Second,
ReaperInterval: time.Second, RobotWaitJitter: 90 * time.Second,
ReaperInterval: 5 * time.Second,
} }
} }
@@ -29,6 +37,9 @@ func (c Config) Validate() error {
if c.RobotWait <= 0 { if c.RobotWait <= 0 {
return fmt.Errorf("lobby: robot wait must be positive, got %s", c.RobotWait) 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 { if c.ReaperInterval <= 0 {
return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval) return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval)
} }
+9 -10
View File
@@ -1,9 +1,10 @@
// Package lobby forms games: an in-memory matchmaking pool that pairs two humans // Package lobby forms games: an auto-match maker that drops a player straight into a
// for an auto-match, and friend-game invitations (invite -> accept) that start a // game with an empty opponent seat (or joins them into another player's waiting one),
// 2-4 player game once every invitee has accepted. Both produce a game through the // and friend-game invitations (invite -> accept) that start a 2-4 player game once
// game domain (a GameCreator); neither imports the engine. The matchmaking pool // every invitee has accepted. Both produce games through the game domain; neither
// is in-memory and lost on restart (players re-queue); the robot that substitutes // imports the engine. Auto-match state is the open games in the database, so it
// for a missing human after a short wait is added in a later stage. // 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 package lobby
import ( import (
@@ -22,8 +23,8 @@ import (
type GameCreator interface { type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error) Create(ctx context.Context, params game.CreateParams) (game.Game, error)
// InitialState returns a seated player's full initial view of a started game, used // 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 // to enrich the game_started event so the client renders the new game without a
// without a follow-up fetch. // follow-up fetch.
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
} }
@@ -51,8 +52,6 @@ const (
// Sentinel errors returned by the lobby. // Sentinel errors returned by the lobby.
var ( 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 // ErrInvalidInvitation is returned for a malformed invitation (bad player
// count, duplicate or self invitee, or unacceptable settings). // count, duplicate or self invitee, or unacceptable settings).
ErrInvalidInvitation = errors.New("lobby: invalid invitation") ErrInvalidInvitation = errors.New("lobby: invalid invitation")
+113 -199
View File
@@ -2,8 +2,7 @@ package lobby
import ( import (
"context" "context"
"math/rand" "math/rand/v2"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -14,182 +13,91 @@ import (
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
) )
// matchKey buckets the auto-match pool: two players are paired only when they chose // GameMatcher is the slice of the game domain the matchmaker drives: opening or
// the same variant and the same per-turn word rule (multipleWords), so a game always // joining an auto-match game, substituting a robot into one whose wait elapsed, and
// starts under a rule both players asked for. // reading a player's view to enrich the opponent_joined event. game.Service satisfies
type matchKey struct { // it.
variant engine.Variant type GameMatcher interface {
multipleWords bool 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 // Matchmaker turns an auto-match enqueue into a real game the player enters at once:
// the next two humans into a two-player game, or — when no human arrives within // it opens a game with an empty opponent seat, or joins the caller into another
// the wait window — substitutes a robot. It holds no database state and is lost on // player's waiting one. A background reaper substitutes a pooled robot for any open
// restart (players simply re-queue). It is safe for concurrent use. // 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 // Auto-match is anonymous, so it does not consult per-user blocks (those govern
// govern friends, chat and invitations between known players). // 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.
type Matchmaker struct { type Matchmaker struct {
games GameCreator games GameMatcher
robots RobotProvider robots RobotProvider
waitDelay time.Duration minWait time.Duration
clock func() time.Time jitter time.Duration
pub notify.Publisher clock func() time.Time
log *zap.Logger pub notify.Publisher
log *zap.Logger
mu sync.Mutex
queues map[matchKey][]uuid.UUID
queued map[uuid.UUID]matchKey
waitingSince map[uuid.UUID]time.Time
results map[uuid.UUID]game.Game
rng *rand.Rand
} }
// NewMatchmaker constructs a Matchmaker that starts matched games through games // NewMatchmaker constructs a Matchmaker that opens auto-match games through games and,
// and substitutes a robot from robots when a player waits longer than waitDelay. // after a per-game wait of minWait plus a random jitter in [0, jitter), substitutes a
func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Duration, log *zap.Logger) *Matchmaker { // 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 { if log == nil {
log = zap.NewNop() log = zap.NewNop()
} }
return &Matchmaker{ return &Matchmaker{
games: games, games: games,
robots: robots, robots: robots,
waitDelay: waitDelay, minWait: minWait,
clock: func() time.Time { return time.Now().UTC() }, jitter: jitter,
pub: notify.Nop{}, clock: func() time.Time { return time.Now().UTC() },
log: log, pub: notify.Nop{},
queues: make(map[matchKey][]uuid.UUID), log: log,
queued: make(map[uuid.UUID]matchKey),
waitingSince: make(map[uuid.UUID]time.Time),
results: make(map[uuid.UUID]game.Game),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
} }
} }
// SetNotifier installs the live-event publisher used to push match_found to the // SetNotifier installs the live-event publisher used to push opponent_joined to a
// seated players when a pairing or robot substitution starts a game. It must be // waiting starter when a human or a robot takes the empty seat. It must be called
// called during startup wiring, before the reaper runs; the default is // during startup wiring, before the reaper runs; the default is notify.Nop (no live
// notify.Nop (no live events; waiters still discover the game via Poll). // events).
func (m *Matchmaker) SetNotifier(p notify.Publisher) { func (m *Matchmaker) SetNotifier(p notify.Publisher) {
if p != nil { if p != nil {
m.pub = p m.pub = p
} }
} }
// emitMatchFound pushes match_found to every seat of a freshly started game. // EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
// Emitting to a robot seat is harmless (no client subscription exists for it). // in, and whether it already had an opponent (they joined a waiting game) rather than
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) { // being freshly opened and still awaiting one.
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.
type EnqueueResult struct { type EnqueueResult struct {
Matched bool Matched bool
Game game.Game Game game.Game
} }
// Enqueue joins accountID to the auto-match pool for variant under the chosen // Enqueue resolves an auto-match request for accountID under variant and the per-turn
// per-turn word rule (multipleWords). If an opponent already waits for the same // word rule (multipleWords) into the game they enter immediately — a freshly opened
// variant and rule, the two are paired (seat order randomised for first-move // game awaiting an opponent, the caller's own still-open game (a re-enqueue is
// fairness) and a game starts immediately; otherwise the account waits, and a later // idempotent), or another player's open game they just joined. When the caller joins
// pairing or robot substitution is delivered through Poll. An account already waiting // an existing game, opponent_joined is pushed to that game's waiting starter.
// in any pool gets ErrAlreadyQueued.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) { func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
key := matchKey{variant: variant, multipleWords: multipleWords} g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
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))
if err != nil { if err != nil {
return EnqueueResult{}, err return EnqueueResult{}, err
} }
// The opponent was waiting; record the game so they can collect it via Poll. if joined {
m.mu.Lock() m.announceOpponent(ctx, g, accountID)
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
} }
return EnqueueResult{}, nil return EnqueueResult{Matched: joined, Game: g}, nil
} }
// Cancel removes accountID from whatever pool it waits in and drops any pending // RunReaper substitutes a robot for any open game past its wait window, scanning every
// matched result, reporting whether it was queued. Clearing the result closes the // interval until ctx is cancelled. It is started once from main.
// 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.
func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) { func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() 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 // Reap substitutes a robot into every open game whose wait window elapsed by now and
// robot and starts the game, recording it for the player's Poll. RunReaper calls // pushes opponent_joined to its starter. RunReaper calls it on a timer; it takes now
// it on a timer; it takes now explicitly so tests and ops can drive a single pass // explicitly so tests and ops can drive a single pass at a chosen instant. A game for
// at a chosen instant. A waiter is only dequeued once a robot is secured, so a // which no robot is available is left for a later tick.
// momentarily empty pool just defers substitution to a later tick.
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
type sub struct { due, err := m.games.ExpiredOpen(ctx, now)
human uuid.UUID if err != nil {
key matchKey m.log.Warn("scan open games", zap.Error(err))
seats []uuid.UUID return
} }
m.mu.Lock() for _, og := range due {
var due []uuid.UUID robotID, err := m.robots.Pick(og.Variant)
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)
if err != nil { if err != nil {
m.log.Warn("robot substitution deferred", zap.Error(err)) m.log.Warn("robot substitution deferred", zap.Error(err))
continue continue
} }
m.removeLocked(acc, key) g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID)
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))
if err != nil { 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 continue
} }
m.mu.Lock() if !attached {
m.results[s.human] = g continue // a human joined first between the scan and the substitution
m.mu.Unlock() }
m.emitMatchFound(ctx, g) m.announceOpponent(ctx, g, robotID)
} }
} }
// removeLocked drops accountID from the queue, the queued index and the waiting // announceOpponent pushes opponent_joined to the game's waiting starter — the seat
// clock. The caller holds m.mu. // that is not joinerID — so its client fills the opponent card and re-enables resign
func (m *Matchmaker) removeLocked(accountID uuid.UUID, key matchKey) { // and chat in place. Routed by the game's language, like every game push.
delete(m.queued, accountID) func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID uuid.UUID) {
delete(m.waitingSince, accountID) starter, ok := otherSeat(g, joinerID)
q := m.queues[key] if !ok {
for i, id := range q { return
if id == accountID { }
m.queues[key] = append(q[:i], q[i+1:]...) state, err := m.games.InitialState(ctx, g.ID, starter)
break 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 // autoMatchParams builds the create parameters for a two-player auto-match with the
// the casual defaults. // casual defaults; the game service assembles the seats and pins the bag seed.
func autoMatchParams(key matchKey, seats []uuid.UUID) game.CreateParams { func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
return game.CreateParams{ return game.CreateParams{
Variant: key.variant, Variant: variant,
Seats: seats,
TurnTimeout: game.DefaultTurnTimeout, TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed, HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer, HintsPerPlayer: autoMatchHintsPerPlayer,
MultipleWordsPerTurn: key.multipleWords, MultipleWordsPerTurn: multipleWords,
} }
} }
+134 -268
View File
@@ -14,28 +14,51 @@ import (
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
) )
// fakeCreator records the games a matchmaker asks it to start. // stubMatcher is a fake GameMatcher: it returns canned games and records the calls the
type fakeCreator struct { // matchmaker makes, so the unit tests cover delegation, the opponent_joined emit and
created []game.CreateParams // the wait-window math without a database. The DB-backed open/join/substitute logic is
err error // 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) { func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
if f.err != nil { s.openCalls++
return game.Game{}, f.err 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) if s.attached {
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil 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 func (s *stubMatcher) ExpiredOpen(_ context.Context, _ time.Time) ([]game.OpenGame, error) {
// tests assert on matching behaviour, not the payload, so an empty state is enough. return s.expired, nil
func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) { }
func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
return notify.PlayerState{}, nil return notify.PlayerState{}, nil
} }
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model // fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an
// an empty pool. It records the variant of the last substitution request. // empty pool. It records the variant of the last substitution request.
type fakeRobots struct { type fakeRobots struct {
id uuid.UUID id uuid.UUID
err error err error
@@ -50,294 +73,137 @@ func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
return f.id, nil return f.id, nil
} }
// testWaitDelay is long enough that the reaper never fires in the pairing tests // capturePub records every published intent.
// (which do not run it); the substitution tests drive reap directly. type capturePub struct{ intents []notify.Intent }
const testWaitDelay = 10 * time.Second
func newTestMatchmaker(creator GameCreator, robotID uuid.UUID) *Matchmaker { func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
return NewMatchmaker(creator, &fakeRobots{id: robotID}, testWaitDelay, zap.NewNop())
}
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool { // twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
for _, w := range want { // (uuid.Nil for a still-empty opponent seat).
found := false func twoSeatGame(starter, opponent uuid.UUID) game.Game {
for _, s := range seats { return game.Game{
if s == w { ID: uuid.New(),
found = true Variant: engine.VariantEnglish,
break Seats: []game.Seat{
} {Seat: 0, AccountID: starter},
} {Seat: 1, AccountID: opponent},
if !found { },
return false
}
} }
return true
} }
func TestMatchmakerPairsTwoHumans(t *testing.T) { func TestEnqueueOpensGameWithoutOpponent(t *testing.T) {
creator := &fakeCreator{} starter := uuid.New()
mm := newTestMatchmaker(creator, uuid.New()) m := &stubMatcher{openGame: twoSeatGame(starter, uuid.Nil)}
ctx := context.Background() pub := &capturePub{}
a, b := uuid.New(), uuid.New() 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 { if err != nil {
t.Fatalf("enqueue a: %v", err) t.Fatalf("enqueue: %v", err)
} }
if r1.Matched { if res.Matched {
t.Fatal("first enqueue must wait, not match") t.Error("opening a game must report Matched=false")
} }
if mm.QueueLen(engine.VariantEnglish) != 1 { if m.openCalls != 1 {
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish)) 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 { 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) t.Fatalf("enqueue: %v", err)
} }
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); !errors.Is(err, ErrAlreadyQueued) { if !res.Matched {
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err) 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) { func TestEnqueueDeadlineWithinWindow(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)
base := time.Now() 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 } 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) t.Fatalf("enqueue: %v", err)
} }
lo, hi := base.Add(90*time.Second), base.Add(180*time.Second)
mm.Reap(ctx, base.Add(5*time.Second)) // before the wait window if m.lastDeadline.Before(lo) || !m.lastDeadline.Before(hi) {
if len(creator.created) != 0 || mm.QueueLen(engine.VariantEnglish) != 1 { t.Errorf("deadline %s not in [%s, %s)", m.lastDeadline, lo, hi)
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)
} }
} }
func TestMatchmakerReaperSkipsCancelled(t *testing.T) { func TestReapSubstitutesRobotAndEmits(t *testing.T) {
creator := &fakeCreator{} human, robotID := uuid.New(), uuid.New()
mm := newTestMatchmaker(creator, uuid.New()) og := game.OpenGame{ID: uuid.New(), Variant: engine.VariantRussianScrabble}
base := time.Now() m := &stubMatcher{
mm.clock = func() time.Time { return base } expired: []game.OpenGame{og},
ctx := context.Background() attachGame: twoSeatGame(human, robotID),
a := uuid.New() attached: true,
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
} }
mm.Cancel(ctx, a) robots := &fakeRobots{id: robotID}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) pub := &capturePub{}
if len(creator.created) != 0 { mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop())
t.Errorf("a cancelled waiter must not be substituted; created %d", len(creator.created)) 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 func TestReapDefersWithoutRobot(t *testing.T) {
// robot just before the player cancels: Cancel must drop the pending result so the m := &stubMatcher{expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}}
// abandoned game never surfaces through Poll. pub := &capturePub{}
func TestMatchmakerCancelClearsPendingResult(t *testing.T) { mm := NewMatchmaker(m, &fakeRobots{err: errors.New("empty pool")}, time.Minute, time.Minute, zap.NewNop())
creator := &fakeCreator{} mm.SetNotifier(pub)
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 { mm.Reap(context.Background(), time.Now())
t.Fatalf("enqueue: %v", err)
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 if len(pub.intents) != 0 {
mm.Cancel(ctx, a) // ... then the player cancels t.Errorf("no robot available: must not emit; got %d intents", len(pub.intents))
if got, _ := mm.Poll(ctx, a); got.Matched {
t.Error("cancel must drop the pending substituted game; Poll still matched")
} }
} }
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) { func TestReapSkipsWhenHumanJoinedFirst(t *testing.T) {
creator := &fakeCreator{} m := &stubMatcher{
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop()) expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}},
base := time.Now() attached: false, // AttachRobot reports the game already filled by a human
mm.clock = func() time.Time { return base } }
ctx := context.Background() pub := &capturePub{}
a := uuid.New() 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 { mm.Reap(context.Background(), time.Now())
t.Fatalf("enqueue: %v", err)
} if len(pub.intents) != 0 {
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) t.Errorf("a human-filled game must not emit opponent_joined; got %d", len(pub.intents))
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")
} }
} }
+16
View File
@@ -122,6 +122,22 @@ func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()} 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 // Notification is a lightweight "re-poll" signal to userID that something in their lobby
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded, // changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the // NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
+5
View File
@@ -24,6 +24,11 @@ const (
KindChatMessage = "chat_message" KindChatMessage = "chat_message"
KindNudge = "nudge" KindNudge = "nudge"
KindMatchFound = "match_found" 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 // KindNotification is a lightweight "re-poll your lobby counters" signal
// (incoming friend requests, invitations) that drives the lobby badge. // (incoming friend requests, invitations) that drives the lobby badge.
KindNotification = "notify" KindNotification = "notify"
@@ -14,7 +14,7 @@ import (
type GamePlayers struct { type GamePlayers struct {
GameID uuid.UUID `sql:"primary_key"` GameID uuid.UUID `sql:"primary_key"`
Seat int16 `sql:"primary_key"` Seat int16 `sql:"primary_key"`
AccountID uuid.UUID AccountID *uuid.UUID
Score int32 Score int32
HintsUsed int16 HintsUsed int16
IsWinner bool IsWinner bool
@@ -29,6 +29,7 @@ type Games struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
FinishedAt *time.Time FinishedAt *time.Time
OpenDeadlineAt *time.Time
DropoutTiles string DropoutTiles string
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
} }
@@ -33,6 +33,7 @@ type gamesTable struct {
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz FinishedAt postgres.ColumnTimestampz
OpenDeadlineAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool MultipleWordsPerTurn postgres.ColumnBool
@@ -92,10 +93,11 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at") FinishedAtColumn = postgres.TimestampzColumn("finished_at")
OpenDeadlineAtColumn = postgres.TimestampzColumn("open_deadline_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles") DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") 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} 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, 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} 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, CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn, UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn, FinishedAt: FinishedAtColumn,
OpenDeadlineAt: OpenDeadlineAtColumn,
DropoutTiles: DropoutTilesColumn, DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn, MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
@@ -96,10 +96,14 @@ CREATE TABLE games (
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz, 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', dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true, multiple_words_per_turn boolean NOT NULL DEFAULT true,
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')), 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_players_chk CHECK (players BETWEEN 2 AND 4),
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players), CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0), 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 -- The sweeper scans active games oldest-turn-first; a partial index keeps it off the
-- finished archive. -- finished archive.
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active'; 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 -- Seats in turn order (seat 0 moves first), one row per player. account_id is a durable
-- durable account. score is the running/final score, is_winner is stamped on finish -- account, or NULL for the still-empty opponent seat of an auto-match game waiting for an
-- (false for every seat on a draw), hints_used counts the per-game allowance consumed -- opponent (status='open'); it is filled when a human or a robot joins. score is the
-- before the profile wallet. -- 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 ( CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL, 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, score integer NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0, hints_used smallint NOT NULL DEFAULT 0,
is_winner boolean NOT NULL DEFAULT false, is_winner boolean NOT NULL DEFAULT false,
+14 -6
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"github.com/google/uuid"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
@@ -186,9 +188,16 @@ const awayTimeLayout = "15:04"
func gameDTOFromGame(g game.Game) gameDTO { func gameDTOFromGame(g game.Game) gameDTO {
seats := make([]seatDTO, 0, len(g.Seats)) seats := make([]seatDTO, 0, len(g.Seats))
for _, s := range 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{ seats = append(seats, seatDTO{
Seat: s.Seat, Seat: s.Seat,
AccountID: s.AccountID.String(), AccountID: accountID,
Score: s.Score, Score: s.Score,
HintsUsed: s.HintsUsed, HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner, IsWinner: s.IsWinner,
@@ -277,13 +286,12 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
return dto, nil 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 { func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
if !r.Matched {
return matchDTO{Matched: false}
}
g := gameDTOFromGame(r.Game) 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. // chatDTOFrom projects a chat message into its DTO.
+2 -4
View File
@@ -76,8 +76,6 @@ func (s *Server) registerRoutes() {
} }
if s.matchmaker != nil { if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue) u.POST("/lobby/enqueue", s.handleEnqueue)
u.POST("/lobby/cancel", s.handleCancel)
u.GET("/lobby/poll", s.handlePoll)
} }
if s.invitations != nil { if s.invitations != nil {
u.GET("/invitations", s.handleListInvitations) u.GET("/invitations", s.handleListInvitations)
@@ -161,14 +159,14 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "nudge_own_turn" return http.StatusConflict, "nudge_own_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive): case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished" return http.StatusConflict, "game_finished"
case errors.Is(err, game.ErrNoOpponentYet):
return http.StatusConflict, "no_opponent_yet"
case errors.Is(err, game.ErrGameActive): case errors.Is(err, game.ErrGameActive):
return http.StatusConflict, "game_active" return http.StatusConflict, "game_active"
case errors.Is(err, account.ErrInvalidProfile): case errors.Is(err, account.ErrInvalidProfile):
return http.StatusBadRequest, "invalid_profile" return http.StatusBadRequest, "invalid_profile"
case errors.Is(err, account.ErrAlreadyConfirmed): case errors.Is(err, account.ErrAlreadyConfirmed):
return http.StatusConflict, "already_confirmed" return http.StatusConflict, "already_confirmed"
case errors.Is(err, lobby.ErrAlreadyQueued):
return http.StatusConflict, "already_queued"
case errors.Is(err, lobby.ErrInvalidInvitation): case errors.Is(err, lobby.ErrInvalidInvitation):
return http.StatusBadRequest, "invalid_invitation" return http.StatusBadRequest, "invalid_invitation"
case errors.Is(err, lobby.ErrInvitationBlocked): case errors.Is(err, lobby.ErrInvitationBlocked):
+4 -35
View File
@@ -133,13 +133,15 @@ func (s *Server) handleGameState(c *gin.Context) {
c.JSON(http.StatusOK, dto) 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 { type enqueueRequest struct {
Variant string `json:"variant"` Variant string `json:"variant"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` 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) { func (s *Server) handleEnqueue(c *gin.Context) {
uid, ok := userID(c) uid, ok := userID(c)
if !ok { if !ok {
@@ -168,39 +170,6 @@ func (s *Server) handleEnqueue(c *gin.Context) {
c.JSON(http.StatusOK, dto) 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. // chatPostRequest posts a per-game chat message.
type chatPostRequest struct { type chatPostRequest struct {
Body string `json:"body"` Body string `json:"body"`
+38 -25
View File
@@ -334,10 +334,11 @@ Key points:
## 7. Robot opponent ## 7. Robot opponent
Substitutes for a human in 2-player auto-match when the pool yields no human Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an
within 10 seconds (§8). It lives in `internal/robot` and plays as an ordinary open game's empty opponent slot when no human has joined within the wait window (§8).
seated account through the game service, so only `internal/engine` imports the It lives in `internal/robot` and plays as an ordinary seated account through the game
solver. It is designed to be indistinguishable from a person. service, so only `internal/engine` imports the solver. It is designed to be
indistinguishable from a person.
The robot keeps **no per-game state**: every choice is derived deterministically The robot keeps **no per-game state**: every choice is derived deterministically
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
@@ -381,17 +382,24 @@ English game the Latin pool.
## 8. Lobby & social ## 8. Lobby & social
- **Matchmaking**: an **in-memory** FIFO pool keyed by `variant` (the variant - **Matchmaking**: auto-match drops the player **straight into a real game and lets
fixes the board language), pairing the next two humans into a two-player them wait inside it**. `Enqueue` (`POST /lobby/enqueue`) opens a game seating the
auto-match with the seat order randomised for first-move fairness. The pool is caller with an **empty opponent seat** (status `open`, §9), or — when another player
lost on restart (players re-queue) and is anonymous, so it does not consult is already waiting for the same `variant` and per-turn rule — seats the caller into
blocks. After **10 s** with no human a background reaper substitutes a pooled that open game and starts it; which seat the caller takes is randomised for
robot (§7) and starts the game. On a pairing or substitution the matchmaker first-move fairness, and a re-enqueue returns the caller's own still-open game
emits a **match-found** notification (§10), delivered over the live stream; (idempotent). Matchmaking state is therefore the **open games in the database** (not
`Poll` remains as a fallback for a client that is not currently streaming. an in-memory pool), so it survives a restart and stays anonymous (no block check);
**Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any concurrent enqueues for one bucket are serialised by a transaction-scoped advisory
pending matched result, so a cancelled quick-match is dequeued rather than left for lock so two callers pair rather than each opening a game. A background **reaper**
the reaper to robot-substitute. seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus
a random **090 s** (so **90180 s** total) — has elapsed, guaranteeing every game
gets an opponent. When a human or a robot takes the seat, the waiting starter
receives an **opponent-joined** notification (§10) that fills the opponent card and
re-enables resign and chat **in place** — the starter never leaves the game. While a
game is `open` the starter may move on their turn, but resign, chat and nudge are
refused (no opponent yet) and the lobby and opponent card show a "searching for
opponent" placeholder.
- **Friends**: two add paths over one `friendships` table. A **one-time - **Friends**: two add paths over one `friendships` table. A **one-time
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric, code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
@@ -462,7 +470,10 @@ English game the Latin pool.
game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one
account's own lobby list, leaving it visible to the other players — finished-only and account's own lobby list, leaving it visible to the other players — finished-only and
irreversible by design, so there is no un-hide). irreversible by design, so there is no un-hide).
The matchmaking pool is **in-memory** and persists nothing. Auto-match has no separate store: a game **awaiting an opponent** is an ordinary
`games` row with status `open` and a single seated `game_players` row (the empty
opponent seat is a null `account_id`, filled when a human or robot joins), plus an
`open_deadline_at` stamp the reaper scans for robot substitution.
- **Active games are event-sourced.** A game is a `games` row (pinned - **Active games are event-sourced.** A game is a `games` row (pinned
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised `variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
turn cursor) plus an append-only, decoded move journal (`game_moves`); the live turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
@@ -470,7 +481,8 @@ English game the Latin pool.
rebuilt by replaying the journal on a miss, which the seeded bag makes exact. rebuilt by replaying the journal on a miss, which the seeded bag makes exact.
Each game is serialised by a per-game lock; a persistence failure evicts the Each game is serialised by a per-game lock; a persistence failure evicts the
live game so the next access rebuilds from the journal. `game_players` records live game so the next access rebuilds from the journal. `game_players` records
each seat's account, running score, hints used and winner flag. each seat's account (**null for an open game's still-empty opponent seat**),
running score, hints used and winner flag.
- **Statistics** (`account_stats`, recomputed on each finish for durable - **Statistics** (`account_stats`, recomputed on each finish for durable
non-guest accounts only — the finish-time recompute skips any `is_guest` non-guest accounts only — the finish-time recompute skips any `is_guest`
seat): wins, losses, **draws**, max points in a game, and seat): wins, losses, **draws**, max points in a game, and
@@ -520,7 +532,7 @@ catalog is **your-turn** and **opponent-moved** (emitted from the game commit, s
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat, robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
including the mover**, so the mover's own other devices and their lobby refresh — it is including the mover**, so the mover's own other devices and their lobby refresh — it is
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge** in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **match-found** (from the matchmaker, §8), and **notify** (from the social service), **opponent-joined** (from the matchmaker, §8), and **notify**
(a lightweight "re-poll" signal carrying a sub-kind: friend-request, (a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation or game-started; emitted on a friend-request, friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original on answering one (accept → friend-added, decline → friend-declined — to the original
@@ -535,16 +547,17 @@ without a follow-up `game.state`: **opponent-moved** carries the committed move
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move
echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history` echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history`
refetch); **your-turn** carries that move count as a consistency check; **match-found** and the refetch); **your-turn** carries that move count as a consistency check; the **game-started**
**game-started** notify carry the recipient's full **initial `StateView`**, so opening a freshly notify carries the recipient's full **initial `StateView`** so opening a freshly started game is
started game is instant; **game-over** carries the final summary; the lobby **notify** sub-kinds instant, and **opponent-joined** carries the waiting starter's refreshed `StateView` so the
opponent card and the resign/chat controls update **in place**; **game-over** carries the final summary; the lobby **notify** sub-kinds
carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` / carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover `exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload (fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters
match-found — the client polls **only while the stream is down**, since a live stream delivers synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined**
match-found itself; for the lobby **notification badge** (incoming friend requests + open event; for the lobby **notification badge** (incoming friend requests + open
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
missed while the app was hidden. **Out-of-app platform push** is a fallback missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no the **gateway** routes from the same firehose: for an event whose recipient has **no
@@ -557,7 +570,7 @@ not the recipient's latest-login bot. It then asks the **Telegram connector** to
localized message with a Mini App deep-link button — only when the recipient has a Telegram localized message with a Mini App deep-link button — only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
your-turn, game-over, nudge, match-found and the invitation / friend-request notify sub-kinds; your-turn, game-over, nudge and the invitation / friend-request notify sub-kinds;
the connector renders the message and skips the rest. Operator broadcasts the connector renders the message and skips the rest. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an (`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and **operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
+11 -6
View File
@@ -41,8 +41,9 @@ language, not whichever bot the player signed in through last. Guests are sessio
(auto-match only; no friends, stats or history); an abandoned guest that never (auto-match only; no friends, stats or history); an abandoned guest that never
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
keeps a live stream and receives in-app updates in real time — the opponent's move, keeps a live stream and receives in-app updates in real time — the opponent's move,
your turn, chat, nudges and a found match. Each update lands as the event itself, applied in place your turn, chat, nudges and an opponent joining a game you are waiting in. Each update lands as the
with no reload, so the board refreshes seamlessly and a found or invited game opens instantly. When the app is **closed**, the chosen event itself, applied in place with no reload, so the board refreshes seamlessly and an invited game
opens instantly. When the app is **closed**, the chosen
out-of-app events (your turn, game over, nudge, a found match, an invitation or friend out-of-app events (your turn, game over, nudge, a found match, an invitation or friend
request) arrive as a **Telegram notification** instead — unless the player keeps request) arrive as a **Telegram notification** instead — unless the player keeps
notifications in the app only (a profile setting, **on by default**). The "your turn" notifications in the app only (a profile setting, **on by default**). The "your turn"
@@ -84,8 +85,12 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and "Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match invitation — so a player still sees and plays existing games of any language. Auto-match
(always 2 players) joins a per-variant pool and is paired with the next waiting human; (always 2 players) drops you **straight into the game and you wait inside it**: if it is your turn you
after 10 s with no human the robot substitutes. For Russian games (auto-match or friend can already move, otherwise you watch your tiles. While no opponent has joined, the opponent card (and
the game's row in the lobby) reads **"searching for opponent"**, and resign, chat and nudge are
unavailable. Another player searching the same variant and rule joins your game; failing that, a robot
takes the empty seat after **1.53 minutes**, so a game always starts — and the New Game screen notes
you can close the app while you wait and come back later. For Russian games (auto-match or friend
invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays
the simplified **single-word rule** — only the word laid along the player's line must be a the simplified **single-word rule** — only the word laid along the player's line must be a
real word, and any incidental perpendicular words are ignored and not scored — while on is real word, and any incidental perpendicular words are ignored and not scored — while on is
@@ -121,8 +126,8 @@ the opponent's turn**, but that draft is position-only — the score preview and
stay available only on the player's own turn. stay available only on the player's own turn.
### Robot opponent ### Robot opponent
When auto-match finds no human within ten seconds, a robot opponent takes the empty When auto-match finds no human within the wait window (1.53 minutes), a robot opponent
seat so the game starts without waiting. It is meant to feel like a person: it takes the empty seat of the game you are already waiting in. It is meant to feel like a person: it
decides once per game whether to play to win (about 40% of the time, so the human decides once per game whether to play to win (about 40% of the time, so the human
wins most games), aims for a close score rather than crushing or throwing the game, wins most games), aims for a close score rather than crushing or throwing the game,
and plays at a human pace — short thinking times for most moves, the occasional long and plays at a human pace — short thinking times for most moves, the occasional long
+12 -8
View File
@@ -42,9 +42,9 @@ nudge) приходят от бота **этой партии** — по язы
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без чат, nudge и подключение соперника к игре, в которой вы ждёте. Каждое обновление приходит самим событием и применяется на месте без
перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие перезагрузки — доска обновляется бесшовно, а приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья) события (ваш ход, конец партии, nudge, приглашение или заявка в друзья)
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
уведомления только в приложении (настройка профиля, **включена по умолчанию**). уведомления только в приложении (настройка профиля, **включена по умолчанию**).
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
@@ -87,9 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса), читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока) любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
игре; если такого нет — через **1,53 минуты** свободное место занимает робот, так что игра всегда
стартует, и экран новой игры подсказывает, что можно закрыть приложение на время ожидания и вернуться
позже. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена — новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются; вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
@@ -126,8 +130,8 @@ nudge) приходят от бота **этой партии** — по язы
предпросмотр счёта и отправка доступны лишь в собственный ход. предпросмотр счёта и отправка доступны лишь в собственный ход.
### Робот-соперник ### Робот-соперник
Если авто-подбор не находит человека за десять секунд, свободное место занимает Если авто-подбор не находит человека за время ожидания (1,5–3 минуты), свободное место в игре,
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека: в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека:
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
+6 -1
View File
@@ -193,7 +193,12 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
of starting a game; a lone offered variant is pre-selected, and a bottom **Start game** of starting a game; a lone offered variant is pre-selected, and a bottom **Start game**
button (disabled until a variant is chosen) confirms. For a **Russian** variant (either button (disabled until a variant is chosen) confirms. For a **Russian** variant (either
flow) a **"Multiple words per turn"** checkbox (`.toggle`, **default off** = the single-word flow) a **"Multiple words per turn"** checkbox (`.toggle`, **default off** = the single-word
rule) appears once that variant is selected; English variants never show it. rule) appears once that variant is selected; English variants never show it. Starting an
auto-match **enters the game immediately** and waits inside it: until an opponent joins, the
opponent's score card (and the game's lobby row) reads the localized **"searching for opponent"**
placeholder, the add-friend 🤝 is hidden, and resign and the chat's send/nudge are disabled; an
**opponent_joined** push restores them in place when a human or robot takes the seat, and a line
under Start game notes the wait can take a while (the app may be closed meanwhile).
- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat
cards (wins / losses / draws / games / win-rate / best game / best move) — pure cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts. numbers, no charts.
+31
View File
@@ -0,0 +1,31 @@
import { expect, test } from './fixtures';
// The quick-match flow drops the player straight into a game that is still waiting for an
// opponent (status 'open'): the opponent card shows "searching for opponent" and resign is
// disabled until the mock attaches a robot shortly after, which restores the game UI. Driven
// entirely by the mock transport (no backend).
test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
// Pick a variant and start; the player lands in the game at once (no "searching" screen).
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the
// history panel) is disabled.
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
await page.locator('.scoreboard').click(); // open the history panel
await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled();
// Attach the opponent deterministically (the mock otherwise joins on a timer).
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
// The opponent card shows its name, the placeholder is gone, and resign is enabled again.
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
});
+6 -2
View File
@@ -8,6 +8,7 @@
myId, myId,
busy, busy,
myTurn = false, myTurn = false,
waiting = false,
nudgeOnCooldown = false, nudgeOnCooldown = false,
onsend, onsend,
onnudge, onnudge,
@@ -20,6 +21,9 @@
// hurry); on the opponent's turn only the nudge button shows. While the hourly nudge // hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
// cooldown is active the nudge is disabled with an "awaiting reply" caption. // cooldown is active the nudge is disabled with an "awaiting reply" caption.
myTurn?: boolean; myTurn?: boolean;
// waiting is true while an auto-match game still has no opponent: both send and nudge
// are disabled (there is no one to message or hurry yet).
waiting?: boolean;
nudgeOnCooldown?: boolean; nudgeOnCooldown?: boolean;
onsend: (text: string) => void; onsend: (text: string) => void;
onnudge: () => void; onnudge: () => void;
@@ -56,11 +60,11 @@
bind:value={text} bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()} onkeydown={(e) => e.key === 'Enter' && send()}
/> />
<button class="iconbtn" onclick={send} disabled={busy || !connection.online} aria-label={t('chat.send')}>⬆️</button> <button class="iconbtn" onclick={send} disabled={busy || waiting || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else} {:else}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. --> <!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span> <span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button> <button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if} {/if}
</div> </div>
</div> </div>
+6 -2
View File
@@ -17,7 +17,11 @@
let tick = $state(0); let tick = $state(0);
const myId = $derived(app.session?.userId ?? ''); const myId = $derived(app.session?.userId ?? '');
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat); const isMyTurn = $derived(
!!view && (view.game.status === 'active' || view.game.status === 'open') && view.game.toMove === view.seat,
);
// While the auto-match game still has no opponent, chat and nudge are both disabled.
const waiting = $derived(!!view && view.game.status === 'open');
const nudgeCooldownSecs = 3600; const nudgeCooldownSecs = 3600;
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the // The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
// backend stays authoritative, so a move-based reset is left to it. // backend stays authoritative, so a move-based reset is left to it.
@@ -87,4 +91,4 @@
} }
</script> </script>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} /> <Chat {messages} {myId} {busy} myTurn={isMyTurn} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
+31 -9
View File
@@ -79,7 +79,7 @@
let recentFlash = $state(false); let recentFlash = $state(false);
function refreshRecent() { function refreshRecent() {
const v = view; const v = view;
if (!v || v.game.status !== 'active') { if (!v || v.game.status === 'finished') {
recent = new Set(); recent = new Set();
recentFlash = false; recentFlash = false;
return; return;
@@ -98,8 +98,12 @@
}); });
const slots = $derived(rackView(placement)); const slots = $derived(rackView(placement));
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index }))); const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat); // 'open' is an auto-match game still waiting for an opponent: the starter may move on their
const gameOver = $derived(!!view && view.game.status !== 'active'); // turn just like an active game, so "playable" covers both; only 'finished' is over.
const waitingForOpponent = $derived(!!view && view.game.status === 'open');
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished');
const bagEmpty = $derived((view?.bagLen ?? 0) === 0); const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
// The seat whose move the history grid awaits with a "thinking…" placeholder: the player to // The seat whose move the history grid awaits with a "thinking…" placeholder: the player to
// move while the game is active, but never the viewer themselves (their own pending cell // move while the game is active, but never the viewer themselves (their own pending cell
@@ -215,6 +219,13 @@
if (view && e.moveCount > view.game.moveCount) void load(); if (view && e.moveCount > view.game.moveCount) void load();
} else if (e.kind === 'game_over' && e.gameId === id) { } else if (e.kind === 'game_over' && e.gameId === id) {
applyDelta(applyGameOver(cacheSnapshot(), e.game)); applyDelta(applyGameOver(cacheSnapshot(), e.game));
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
// The opponent took the empty seat: adopt the new participants and status in place,
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
if (view) {
view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } };
setCachedGame(id, view, moves);
}
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) { } else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
// A request the player sent was answered: re-derive the in-game "add friend" state. // A request the player sent was answered: re-derive the in-game "add friend" state.
void loadFriends(); void loadFriends();
@@ -748,10 +759,21 @@
} }
} }
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is // seatName renders a seat's name: "you" for the viewer, the localized "searching for
// not yet a friend (an already-requested opponent still shows it, but disabled). // opponent" placeholder for an open game's still-empty seat (no account), otherwise the
// display name.
function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you');
if (!s.accountId) return t('game.searchingForOpponent');
return s.displayName;
}
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled).
function canAddFriend(accountId: string): boolean { function canAddFriend(accountId: string): boolean {
return !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId); return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
} }
</script> </script>
@@ -763,7 +785,7 @@
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if} {#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
{#each view.game.seats as s (s.seat)} {#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}> <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div> <div class="nm">{seatName(s)}</div>
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div> <div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
{#if historyOpen && canAddFriend(s.accountId)} {#if historyOpen && canAddFriend(s.accountId)}
<span class="addfriend"> <span class="addfriend">
@@ -788,7 +810,7 @@
{#if gameOver} {#if gameOver}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button> <button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{:else} {:else}
<button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if} {#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}> <button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
@@ -855,7 +877,7 @@
{#if gameOver} {#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong> <strong class="over">{t('game.over')}{resultText()}</strong>
{:else if placement.pending.length === 0} {:else if placement.pending.length === 0}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span> <span class="turn-ind">{isMyTurn ? t('game.yourTurn') : seatName(view.game.seats[view.game.toMove])}</span>
{/if} {/if}
<span class="scores"> <span class="scores">
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if} {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
+14
View File
@@ -4,6 +4,7 @@ import * as fb from '../gen/fbs/scrabblefb';
import { BLANK_INDEX, setAlphabet } from './alphabet'; import { BLANK_INDEX, setAlphabet } from './alphabet';
import { import {
decodeDraftView, decodeDraftView,
decodeEvent,
decodeFriendList, decodeFriendList,
decodeGameList, decodeGameList,
decodeInvitation, decodeInvitation,
@@ -264,6 +265,19 @@ describe('codec', () => {
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' }); expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
expect(inv.variant).toBe('scrabble_en'); expect(inv.variant).toBe('scrabble_en');
}); });
it('decodes an opponent_joined event (reusing the match_found payload layout)', () => {
const b = new Builder(64);
const gid = b.createString('g-open');
fb.MatchFoundEvent.startMatchFoundEvent(b);
fb.MatchFoundEvent.addGameId(b, gid);
b.finish(fb.MatchFoundEvent.endMatchFoundEvent(b));
expect(decodeEvent('opponent_joined', b.asUint8Array())).toEqual({
kind: 'opponent_joined',
gameId: 'g-open',
state: undefined,
});
});
}); });
// The live play loop exchanges alphabet indices, mapped through the per-variant // The live play loop exchanges alphabet indices, mapped through the per-variant
+6
View File
@@ -465,6 +465,12 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
const st = e.state(); const st = e.state();
return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined }; return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
} }
case 'opponent_joined': {
// opponent_joined reuses the match_found payload layout (game id + the recipient's state).
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
const st = e.state();
return { kind: 'opponent_joined', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
}
case 'notify': { case 'notify': {
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb); const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
const acc = e.account(); const acc = e.account();
+5
View File
@@ -20,4 +20,9 @@ if (isMock && typeof window !== 'undefined') {
offline: reportOffline, offline: reportOffline,
online: reportOnline, online: reportOnline,
}; };
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(window as unknown as { __mock?: { joinOpponent(): void } }).__mock = {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
};
} }
+3
View File
@@ -51,11 +51,14 @@ export const en = {
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50', 'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15', 'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
'new.moveLimit': 'Move time: {n} h 00 min', 'new.moveLimit': 'Move time: {n} h 00 min',
'new.searchHint':
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
'game.bag': '{n} in the bag', 'game.bag': '{n} in the bag',
'game.bagEmpty': 'Bag is empty', 'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}', 'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn', 'game.yourTurn': 'Your turn',
'game.searchingForOpponent': 'Searching for opponent…',
'game.waiting': "Waiting for {name}", 'game.waiting': "Waiting for {name}",
'game.makeMove': 'Make move', 'game.makeMove': 'Make move',
'game.reset': 'Reset', 'game.reset': 'Reset',
+3
View File
@@ -52,11 +52,14 @@ export const ru: Record<MessageKey, string> = {
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50', 'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15', 'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.', 'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
'game.bag': '{n} в мешке', 'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст', 'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}', 'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход', 'game.yourTurn': 'Ваш ход',
'game.searchingForOpponent': 'Поиск соперника...',
'game.waiting': 'Ожидаем {name}', 'game.waiting': 'Ожидаем {name}',
'game.makeMove': 'Сделать ход', 'game.makeMove': 'Сделать ход',
'game.reset': 'Сброс', 'game.reset': 'Сброс',
+35 -8
View File
@@ -88,6 +88,8 @@ export class MockGateway implements GatewayClient {
private readonly profile: Profile = { ...PROFILE }; private readonly profile: Profile = { ...PROFILE };
private readonly subs = new Set<(e: PushEvent) => void>(); private readonly subs = new Set<(e: PushEvent) => void>();
private pendingMatch: string | null = null; private pendingMatch: string | null = null;
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
private openGameId: string | null = null;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f })); private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f })); private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
private outgoing: AccountRef[] = []; private outgoing: AccountRef[] = [];
@@ -143,14 +145,16 @@ export class MockGateway implements GatewayClient {
// --- lobby --- // --- lobby ---
async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> { async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> {
// Simulate a 10s-style robot substitution, sped up: match found shortly. // The player enters an open game immediately and waits inside it; a robot opponent takes
// the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing
// opponent_joined so the game UI restores from the "searching for opponent" state.
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const g: MockGame = { const g: MockGame = {
view: { view: {
id, id,
variant, variant,
dictVersion: 'v1', dictVersion: 'v1',
status: 'active', status: 'open',
players: 2, players: 2,
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
@@ -160,7 +164,7 @@ export class MockGateway implements GatewayClient {
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
seats: [ seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
], ],
}, },
moves: [], moves: [],
@@ -170,9 +174,28 @@ export class MockGateway implements GatewayClient {
chat: [], chat: [],
}; };
this.games.set(id, g); this.games.set(id, g);
this.pendingMatch = id; this.openGameId = id;
setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400); // The opponent joins on a timer for manual mock play; the e2e triggers it deterministically
return { matched: false }; // through the __mock hook (see lib/gateway.ts).
setTimeout(() => this.fillOpponent(id), 3000);
return { matched: false, game: structuredClone(g.view) };
}
// fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
// mock of a human or robot taking the seat. A no-op once the game is no longer open.
private fillOpponent(id: string): void {
const game = this.games.get(id);
if (!game || game.view.status !== 'open') return;
game.view.status = 'active';
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
}
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
// opponent to the most recently opened game on demand, making the waiting → joined transition
// deterministic.
joinPendingOpponent(): void {
if (this.openGameId) this.fillOpponent(this.openGameId);
} }
async lobbyPoll(): Promise<MatchResult> { async lobbyPoll(): Promise<MatchResult> {
@@ -190,8 +213,8 @@ export class MockGateway implements GatewayClient {
} }
// --- game --- // --- game ---
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> { // stateOf builds a player's StateView from a mock game (the viewer is always ME).
const g = this.game(gameId); private stateOf(g: MockGame): StateView {
return { return {
game: structuredClone(g.view), game: structuredClone(g.view),
seat: this.mySeat(g), seat: this.mySeat(g),
@@ -201,6 +224,10 @@ export class MockGateway implements GatewayClient {
}; };
} }
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
return this.stateOf(this.game(gameId));
}
async gameHistory(gameId: string): Promise<History> { async gameHistory(gameId: string): Promise<History> {
const g = this.game(gameId); const g = this.game(gameId);
return { gameId, moves: structuredClone(g.moves) }; return { gameId, moves: structuredClone(g.moves) };
+6 -4
View File
@@ -5,8 +5,9 @@
export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru'; export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';
/** Backend game status strings. */ /** Backend game status strings. 'open' is an auto-match game the player has entered
export type GameStatus = 'active' | 'finished' | string; * but which is still waiting for an opponent (the opponent seat has no account). */
export type GameStatus = 'active' | 'finished' | 'open' | string;
/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */ /** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */
export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string; export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string;
@@ -237,8 +238,8 @@ export interface GameList {
/** /**
* A live event delivered over the Subscribe stream. The game events carry the move as a * A live event delivered over the Subscribe stream. The game events carry the move as a
* delta — move plus the post-move summary (and the bag size) — the client applies to its * delta — move plus the post-move summary (and the bag size) — the client applies to its
* cached game without a refetch; match_found / game_started carry the recipient's initial * cached game without a refetch; match_found / game_started / opponent_joined carry the
* StateView; notify carries the changed lobby payload. The enriched fields are optional * recipient's StateView; notify carries the changed lobby payload. The enriched fields are optional
* so a client falls back to a refetch when a payload is absent (a gap, or an older peer). * so a client falls back to a refetch when a payload is absent (a gap, or an older peer).
*/ */
export type PushEvent = export type PushEvent =
@@ -248,5 +249,6 @@ export type PushEvent =
| { kind: 'chat_message'; message: ChatMessage } | { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string } | { kind: 'nudge'; gameId: string; fromUserId: string }
| { kind: 'match_found'; gameId: string; state?: StateView } | { kind: 'match_found'; gameId: string; state?: StateView }
| { kind: 'opponent_joined'; gameId: string; state?: StateView }
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView } | { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
| { kind: 'heartbeat' }; | { kind: 'heartbeat' };
+2
View File
@@ -52,6 +52,8 @@
const groups = $derived(groupGames(games, myId)); const groups = $derived(groupGames(games, myId));
function opponents(g: GameView): string { function opponents(g: GameView): string {
// An auto-match game still waiting for an opponent shows the "searching" placeholder.
if (g.status === 'open') return t('game.searchingForOpponent');
return g.seats return g.seats
.filter((s) => s.accountId !== myId) .filter((s) => s.accountId !== myId)
.map((s) => s.displayName) .map((s) => s.displayName)
+18 -105
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
@@ -41,79 +41,25 @@
let mode = $state<'auto' | 'friends'>('auto'); let mode = $state<'auto' | 'friends'>('auto');
// --- auto-match --- // --- auto-match ---
let searching = $state(false); // Enqueue drops the player straight into a real game — a freshly opened one awaiting an
// matched guards the teardown: once a match arrives (immediately, via the match_found push, or // opponent, or another player's open game they just joined — so we navigate into it at once
// via the fallback poll) onDestroy must not dequeue the game we just got. // and the player waits inside. The opponent (a human or, after the wait, a robot) takes the
let matched = $state(false); // empty seat later via the opponent_joined push; there is no separate "searching" screen.
let poll: ReturnType<typeof setInterval> | null = null; let starting = $state(false);
function stop() {
if (poll) {
clearInterval(poll);
poll = null;
}
}
// startPoll is the matchmaking fallback used only while the live stream is down: with the stream
// up the match_found push drives navigation. It polls lobby.poll every 2.5s.
function startPoll() {
if (poll) return;
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
matched = true;
searching = false;
stop();
navigate(`/game/${p.game.id}`);
}
} catch (e) {
handleError(e);
}
}, 2500);
}
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
// reaper later substitutes a robot for a game the player abandoned.
function cancelSearch() {
stop();
searching = false;
void gateway.lobbyCancel().catch(() => {});
navigate('/');
}
async function find(v: Variant) { async function find(v: Variant) {
searching = true; if (starting) return;
matched = false; starting = true;
try { try {
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords)); const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords));
if (r.matched && r.game) { if (r.game) navigate(`/game/${r.game.id}`);
matched = true;
searching = false;
navigate(`/game/${r.game.id}`);
}
} catch (e) { } catch (e) {
searching = false;
handleError(e); handleError(e);
} finally {
starting = false;
} }
// No immediate match: wait for the match_found push; the effect below polls only when the
// stream is down.
} }
// Poll for the match only while searching and the stream is down (the push cannot reach us);
// stop once the stream is back or the search ends.
$effect(() => {
if (searching && !app.streamAlive) startPoll();
else stop();
});
// match_found is handled globally (app.svelte navigates); end the search here too so onDestroy
// does not cancel the match we just received.
$effect(() => {
if (app.lastEvent?.kind === 'match_found' && searching) {
matched = true;
searching = false;
}
});
// --- friend game --- // --- friend game ---
let friends = $state<AccountRef[]>([]); let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]); let selected = $state<string[]>([]);
@@ -161,23 +107,10 @@
} }
} }
onDestroy(() => {
stop();
// Abandoned mid-search without a match (navigated away without Cancel): dequeue so we don't
// linger. A received match (matched) must not be cancelled.
if (searching && !matched) void gateway.lobbyCancel().catch(() => {});
});
</script> </script>
<Screen title={t('new.title')} back="/"> <Screen title={t('new.title')} back="/">
<div class="page"> <div class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={cancelSearch}>{t('common.cancel')}</button>
</div>
{:else}
{#if !guest} {#if !guest}
<div class="seg modes"> <div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button> <button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
@@ -214,9 +147,10 @@
</label> </label>
{/if} {/if}
<p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<p class="searchhint">{t('new.searchHint')}</p>
<button <button
class="invite" class="invite"
disabled={!selectedAuto || !connection.online} disabled={!selectedAuto || !connection.online || starting}
onclick={() => selectedAuto && find(selectedAuto)} onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button> >{t('new.start')}</button>
{:else if friends.length === 0} {:else if friends.length === 0}
@@ -266,7 +200,6 @@
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button> <button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div> </div>
{/if} {/if}
{/if}
</div> </div>
</Screen> </Screen>
@@ -460,31 +393,11 @@
.invite:disabled { .invite:disabled {
opacity: 0.5; opacity: 0.5;
} }
.searching { .searchhint {
display: grid; margin: 0;
place-items: center; text-align: center;
gap: 14px;
padding: 48px 0;
color: var(--text-muted); color: var(--text-muted);
} font-size: 0.85rem;
.spinner { line-height: 1.4;
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cancel {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
} }
</style> </style>