Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 9s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 9s

Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged.

New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer).

Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-02 19:29:30 +02:00
parent 571bc8c9f2
commit bfa8797f8c
54 changed files with 4270 additions and 81 deletions
+112
View File
@@ -0,0 +1,112 @@
package lobby
import (
"context"
"math/rand"
"sync"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
// the next two humans into a two-player game. It holds no database state and is
// lost on restart (players simply re-queue). It 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). Robot substitution
// for a missing human is added in a later stage.
type Matchmaker struct {
games GameCreator
mu sync.Mutex
queues map[engine.Variant][]uuid.UUID
queued map[uuid.UUID]engine.Variant
rng *rand.Rand
}
// NewMatchmaker constructs a Matchmaker that starts matched games through games.
func NewMatchmaker(games GameCreator) *Matchmaker {
return &Matchmaker{
games: games,
queues: make(map[engine.Variant][]uuid.UUID),
queued: make(map[uuid.UUID]engine.Variant),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// EnqueueResult reports the outcome of joining the pool: either a started game or a
// queued ticket awaiting an opponent.
type EnqueueResult struct {
Matched bool
Game game.Game
}
// Enqueue joins accountID to the variant pool. If an opponent already waits, the
// two are paired (seat order randomised for first-move fairness) and a game starts
// immediately; otherwise the account waits. An account already waiting in any pool
// gets ErrAlreadyQueued.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) {
m.mu.Lock()
if _, ok := m.queued[accountID]; ok {
m.mu.Unlock()
return EnqueueResult{}, ErrAlreadyQueued
}
q := m.queues[variant]
if len(q) == 0 {
m.queues[variant] = append(q, accountID)
m.queued[accountID] = variant
m.mu.Unlock()
return EnqueueResult{}, nil
}
opponent := q[0]
m.queues[variant] = q[1:]
delete(m.queued, opponent)
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, game.CreateParams{
Variant: variant,
Seats: seats,
TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
})
if err != nil {
return EnqueueResult{}, err
}
return EnqueueResult{Matched: true, Game: g}, nil
}
// Cancel removes accountID from whatever pool it waits in, reporting whether it
// was queued.
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
m.mu.Lock()
defer m.mu.Unlock()
variant, ok := m.queued[accountID]
if !ok {
return false
}
delete(m.queued, accountID)
q := m.queues[variant]
for i, id := range q {
if id == accountID {
m.queues[variant] = append(q[:i], q[i+1:]...)
break
}
}
return true
}
// QueueLen returns the number of accounts waiting in the variant pool.
func (m *Matchmaker) QueueLen(variant engine.Variant) int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.queues[variant])
}