81b9e1529e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.
- backend: a directional blockExists guard across chat/nudge/friends/invitations
(store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
the matchmaker excludes a block-related pair (both directions) from auto-match;
user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
(red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
260 lines
10 KiB
Go
260 lines
10 KiB
Go
package lobby
|
|
|
|
import (
|
|
"context"
|
|
"math/rand/v2"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/notify"
|
|
)
|
|
|
|
// 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 joins the caller into another waiting player's open game or opens a fresh
|
|
// one; exclude lists account IDs whose open game must never be joined (the caller's
|
|
// per-user block set), so a block keeps the pair out of the same anonymous game.
|
|
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
|
|
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (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)
|
|
// Create seats the given accounts in an active game at once; the AI-match path uses
|
|
// it to start a game already seated with a robot (no open/wait phase).
|
|
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
|
}
|
|
|
|
// 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, but it still consults per-user blocks: a player is never
|
|
// paired into a game whose waiting opponent they have blocked, or who has blocked them
|
|
// (either direction), so a block keeps two players apart everywhere.
|
|
type Matchmaker struct {
|
|
games GameMatcher
|
|
robots RobotProvider
|
|
minWait time.Duration
|
|
jitter time.Duration
|
|
clock func() time.Time
|
|
pub notify.Publisher
|
|
blocker Blocker
|
|
log *zap.Logger
|
|
}
|
|
|
|
// 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,
|
|
minWait: minWait,
|
|
jitter: jitter,
|
|
clock: func() time.Time { return time.Now().UTC() },
|
|
pub: notify.Nop{},
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// SetNotifier installs the live-event publisher used to push 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
|
|
}
|
|
}
|
|
|
|
// SetBlocker installs the per-user block source used to keep auto-match from pairing a
|
|
// player with someone they have a block with (either direction). It must be called
|
|
// during startup wiring; the default is no blocker, in which case auto-match pairs
|
|
// anonymously (no block exclusion).
|
|
func (m *Matchmaker) SetBlocker(b Blocker) {
|
|
if b != nil {
|
|
m.blocker = b
|
|
}
|
|
}
|
|
|
|
// 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 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, or another player's open game they just joined. A
|
|
// re-enqueue while already waiting opens another game rather than returning the
|
|
// caller's own. 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) {
|
|
// Exclude every account the caller has a block with (either direction) from the
|
|
// candidate open games, so auto-match never pairs a blocked pair into one game.
|
|
var exclude []uuid.UUID
|
|
if m.blocker != nil {
|
|
var err error
|
|
exclude, err = m.blocker.BlockedWith(ctx, accountID)
|
|
if err != nil {
|
|
return EnqueueResult{}, err
|
|
}
|
|
}
|
|
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
|
|
if err != nil {
|
|
return EnqueueResult{}, err
|
|
}
|
|
if joined {
|
|
m.announceOpponent(ctx, g, accountID)
|
|
}
|
|
return EnqueueResult{Matched: joined, Game: g}, nil
|
|
}
|
|
|
|
// StartVsAI creates an honest-AI game for accountID against a pooled robot, returning
|
|
// the game the player enters at once. Unlike Enqueue it never opens or waits: it picks
|
|
// a robot for the variant, seats the human and the robot in a random order (seat 0
|
|
// moves first), and creates an active game flagged vs_ai. The flag makes the robot
|
|
// reply immediately and suppresses chat, nudge and statistics; the robot's first move
|
|
// (when it is seated first) follows on the game service's fast-move trigger.
|
|
func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
|
robotID, err := m.robots.Pick(variant)
|
|
if err != nil {
|
|
return EnqueueResult{}, err
|
|
}
|
|
params := aiMatchParams(variant, multipleWords)
|
|
params.Seats = []uuid.UUID{accountID, robotID}
|
|
if rand.IntN(2) == 1 {
|
|
params.Seats = []uuid.UUID{robotID, accountID}
|
|
}
|
|
g, err := m.games.Create(ctx, params)
|
|
if err != nil {
|
|
return EnqueueResult{}, err
|
|
}
|
|
return EnqueueResult{Matched: true, Game: g}, nil
|
|
}
|
|
|
|
// 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()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
m.Reap(ctx, m.clock())
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
due, err := m.games.ExpiredOpen(ctx, now)
|
|
if err != nil {
|
|
m.log.Warn("scan open games", zap.Error(err))
|
|
return
|
|
}
|
|
for _, og := range due {
|
|
robotID, name, err := m.robots.PickNamed(og.Variant)
|
|
if err != nil {
|
|
m.log.Warn("robot substitution deferred", zap.Error(err))
|
|
continue
|
|
}
|
|
g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID, name)
|
|
if err != nil {
|
|
m.log.Warn("robot substitution failed", zap.String("game", og.ID.String()), zap.Error(err))
|
|
continue
|
|
}
|
|
if !attached {
|
|
continue // a human joined first between the scan and the substitution
|
|
}
|
|
m.announceOpponent(ctx, g, robotID)
|
|
}
|
|
}
|
|
|
|
// announceOpponent pushes opponent_joined to the game's waiting starter — the seat
|
|
// that is not joinerID — so its client fills the opponent card and re-enables resign
|
|
// and chat in place. Routed by the game's language, like every game push.
|
|
func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID uuid.UUID) {
|
|
starter, ok := otherSeat(g, joinerID)
|
|
if !ok {
|
|
return
|
|
}
|
|
state, err := m.games.InitialState(ctx, g.ID, starter)
|
|
if err != nil {
|
|
m.log.Warn("opponent_joined initial state",
|
|
zap.String("game", g.ID.String()), zap.String("account", starter.String()), zap.Error(err))
|
|
return
|
|
}
|
|
intent := notify.OpponentJoined(starter, g.ID, state)
|
|
intent.Language = g.Variant.Language()
|
|
m.pub.Publish(intent)
|
|
}
|
|
|
|
// openDeadline is when the reaper substitutes a robot for a game opened now: a fixed
|
|
// minimum wait plus a random jitter, so the substitution time varies per game.
|
|
func (m *Matchmaker) openDeadline() time.Time {
|
|
d := m.minWait
|
|
if m.jitter > 0 {
|
|
d += rand.N(m.jitter)
|
|
}
|
|
return m.clock().Add(d)
|
|
}
|
|
|
|
// otherSeat returns the account at the seat that is not accountID — the open game's
|
|
// starter when accountID is the joiner — and false when no seat differs or it is still
|
|
// empty.
|
|
func otherSeat(g game.Game, accountID uuid.UUID) (uuid.UUID, bool) {
|
|
for _, s := range g.Seats {
|
|
if s.AccountID != accountID && s.AccountID != uuid.Nil {
|
|
return s.AccountID, true
|
|
}
|
|
}
|
|
return uuid.Nil, false
|
|
}
|
|
|
|
// autoMatchParams builds the create parameters for a two-player auto-match with the
|
|
// casual defaults; the game service assembles the seats and pins the bag seed.
|
|
func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
|
|
return game.CreateParams{
|
|
Variant: variant,
|
|
TurnTimeout: game.DefaultTurnTimeout,
|
|
HintsAllowed: autoMatchHintsAllowed,
|
|
HintsPerPlayer: autoMatchHintsPerPlayer,
|
|
MultipleWordsPerTurn: multipleWords,
|
|
}
|
|
}
|
|
|
|
// aiMatchParams builds the create parameters for an honest-AI two-player game: the
|
|
// same casual auto-match defaults plus the vs_ai flag. The 7-day inactivity clock is
|
|
// applied by the game service for vs_ai games, so TurnTimeout is left zero; the caller
|
|
// fills Seats with the human and the picked robot.
|
|
func aiMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
|
|
return game.CreateParams{
|
|
Variant: variant,
|
|
HintsAllowed: autoMatchHintsAllowed,
|
|
HintsPerPlayer: autoMatchHintsPerPlayer,
|
|
MultipleWordsPerTurn: multipleWords,
|
|
VsAI: true,
|
|
}
|
|
}
|