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