Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
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:
@@ -0,0 +1,459 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// invitationTTL is how long an unanswered invitation stays open before it lazily
|
||||
// expires.
|
||||
const invitationTTL = 7 * 24 * time.Hour
|
||||
|
||||
// Invitation statuses.
|
||||
const (
|
||||
invitationPending = "pending"
|
||||
invitationDeclined = "declined"
|
||||
invitationCancelled = "cancelled"
|
||||
invitationExpired = "expired"
|
||||
invitationStarted = "started"
|
||||
)
|
||||
|
||||
// Invitee responses.
|
||||
const (
|
||||
inviteePending = "pending"
|
||||
inviteeAccepted = "accepted"
|
||||
inviteeDeclined = "declined"
|
||||
)
|
||||
|
||||
// InvitationSettings are the game settings an inviter chooses. A zero TurnTimeout
|
||||
// defaults to game.DefaultTurnTimeout; the zero DropoutTiles removes a leaver's
|
||||
// tiles from play.
|
||||
type InvitationSettings struct {
|
||||
Variant engine.Variant
|
||||
TurnTimeout time.Duration
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int
|
||||
DropoutTiles engine.DropoutTiles
|
||||
}
|
||||
|
||||
// Invitee is one invited player's seat and response.
|
||||
type Invitee struct {
|
||||
AccountID uuid.UUID
|
||||
Seat int
|
||||
Response string
|
||||
}
|
||||
|
||||
// Invitation is a friend-game invitation with its invitees.
|
||||
type Invitation struct {
|
||||
ID uuid.UUID
|
||||
InviterID uuid.UUID
|
||||
Settings InvitationSettings
|
||||
Status string
|
||||
GameID *uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
Invitees []Invitee
|
||||
}
|
||||
|
||||
// InvitationService creates and resolves friend-game invitations, starting the
|
||||
// game through a GameCreator once every invitee has accepted.
|
||||
type InvitationService struct {
|
||||
store *Store
|
||||
games GameCreator
|
||||
accounts *account.Store
|
||||
blocker Blocker
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewInvitationService constructs an InvitationService. store owns the invitation
|
||||
// tables; games starts the accepted game; accounts validates invitees; blocker
|
||||
// refuses invitations across a block.
|
||||
func NewInvitationService(store *Store, games GameCreator, accounts *account.Store, blocker Blocker) *InvitationService {
|
||||
return &InvitationService{
|
||||
store: store,
|
||||
games: games,
|
||||
accounts: accounts,
|
||||
blocker: blocker,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
|
||||
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
|
||||
// invitees distinct and not the inviter, every invitee an existing account with no
|
||||
// block standing between them, and the settings acceptable.
|
||||
func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uuid.UUID, inviteeIDs []uuid.UUID, settings InvitationSettings) (Invitation, error) {
|
||||
if n := len(inviteeIDs) + 1; n < 2 || n > 4 {
|
||||
return Invitation{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidInvitation, n)
|
||||
}
|
||||
if settings.HintsPerPlayer < 0 {
|
||||
return Invitation{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidInvitation)
|
||||
}
|
||||
if settings.TurnTimeout == 0 {
|
||||
settings.TurnTimeout = game.DefaultTurnTimeout
|
||||
}
|
||||
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
|
||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||
}
|
||||
seen := map[uuid.UUID]bool{inviterID: true}
|
||||
for _, id := range inviteeIDs {
|
||||
if seen[id] {
|
||||
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
|
||||
}
|
||||
seen[id] = true
|
||||
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
return Invitation{}, fmt.Errorf("%w: invitee %s not found", ErrInvalidInvitation, id)
|
||||
}
|
||||
return Invitation{}, err
|
||||
}
|
||||
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if blocked {
|
||||
return Invitation{}, ErrInvitationBlocked
|
||||
}
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err)
|
||||
}
|
||||
ins := invitationInsert{
|
||||
id: id,
|
||||
inviterID: inviterID,
|
||||
variant: settings.Variant.String(),
|
||||
turnTimeoutSecs: int(settings.TurnTimeout / time.Second),
|
||||
hintsAllowed: settings.HintsAllowed,
|
||||
hintsPerPlayer: settings.HintsPerPlayer,
|
||||
dropoutTiles: settings.DropoutTiles.String(),
|
||||
expiresAt: svc.now().Add(invitationTTL),
|
||||
}
|
||||
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
return svc.store.loadInvitation(ctx, id)
|
||||
}
|
||||
|
||||
// RespondInvitation records accountID's accept or decline of an invitation. A
|
||||
// decline cancels the whole invitation; the accept that completes the set starts
|
||||
// the game and marks the invitation started.
|
||||
func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationID, accountID uuid.UUID, accept bool) (Invitation, error) {
|
||||
res, err := svc.store.respondTx(ctx, invitationID, accountID, accept, svc.now())
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if accept && res.allAccepted {
|
||||
if err := svc.startGame(ctx, invitationID); err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
}
|
||||
return svc.store.loadInvitation(ctx, invitationID)
|
||||
}
|
||||
|
||||
// startGame creates the game for a fully-accepted invitation and marks it started.
|
||||
func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.UUID) error {
|
||||
inv, err := svc.store.loadInvitation(ctx, invitationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seats := make([]uuid.UUID, len(inv.Invitees)+1)
|
||||
seats[0] = inv.InviterID
|
||||
for _, iv := range inv.Invitees {
|
||||
if iv.Seat < 1 || iv.Seat >= len(seats) {
|
||||
return fmt.Errorf("lobby: invitation %s has out-of-range seat %d", invitationID, iv.Seat)
|
||||
}
|
||||
seats[iv.Seat] = iv.AccountID
|
||||
}
|
||||
g, err := svc.games.Create(ctx, game.CreateParams{
|
||||
Variant: inv.Settings.Variant,
|
||||
Seats: seats,
|
||||
TurnTimeout: inv.Settings.TurnTimeout,
|
||||
HintsAllowed: inv.Settings.HintsAllowed,
|
||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||
DropoutTiles: inv.Settings.DropoutTiles,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelInvitation lets the inviter withdraw a pending invitation.
|
||||
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error {
|
||||
return svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now())
|
||||
}
|
||||
|
||||
// GetInvitation loads an invitation with its invitees.
|
||||
func (svc *InvitationService) GetInvitation(ctx context.Context, invitationID uuid.UUID) (Invitation, error) {
|
||||
return svc.store.loadInvitation(ctx, invitationID)
|
||||
}
|
||||
|
||||
// invitationInsert carries the immutable fields of a new invitation.
|
||||
type invitationInsert struct {
|
||||
id uuid.UUID
|
||||
inviterID uuid.UUID
|
||||
variant string
|
||||
turnTimeoutSecs int
|
||||
hintsAllowed bool
|
||||
hintsPerPlayer int
|
||||
dropoutTiles string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// respondResult reports the state after an invitee response.
|
||||
type respondResult struct {
|
||||
allAccepted bool
|
||||
}
|
||||
|
||||
// insertInvitation inserts the invitation and one invitee row per id (seats 1..N).
|
||||
func (s *Store) insertInvitation(ctx context.Context, ins invitationInsert, inviteeIDs []uuid.UUID) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
ii := table.GameInvitations.INSERT(
|
||||
table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
|
||||
table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
|
||||
table.GameInvitations.DropoutTiles, table.GameInvitations.ExpiresAt,
|
||||
).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.expiresAt)
|
||||
if _, err := ii.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert invitation: %w", err)
|
||||
}
|
||||
for i, id := range inviteeIDs {
|
||||
pi := table.GameInvitationInvitees.INSERT(
|
||||
table.GameInvitationInvitees.InvitationID, table.GameInvitationInvitees.AccountID, table.GameInvitationInvitees.Seat,
|
||||
).VALUES(ins.id, id, i+1)
|
||||
if _, err := pi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert invitee %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// loadInvitation reads an invitation and its invitees ordered by seat.
|
||||
func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, error) {
|
||||
isel := postgres.SELECT(table.GameInvitations.AllColumns).
|
||||
FROM(table.GameInvitations).
|
||||
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
var row model.GameInvitations
|
||||
if err := isel.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Invitation{}, ErrInvitationNotFound
|
||||
}
|
||||
return Invitation{}, fmt.Errorf("lobby: load invitation %s: %w", id, err)
|
||||
}
|
||||
variant, err := engine.ParseVariant(row.Variant)
|
||||
if err != nil {
|
||||
return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
|
||||
}
|
||||
dropout, err := engine.ParseDropoutTiles(row.DropoutTiles)
|
||||
if err != nil {
|
||||
return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
|
||||
}
|
||||
inv := Invitation{
|
||||
ID: row.InvitationID,
|
||||
InviterID: row.InviterID,
|
||||
Settings: InvitationSettings{
|
||||
Variant: variant,
|
||||
TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second,
|
||||
HintsAllowed: row.HintsAllowed,
|
||||
HintsPerPlayer: int(row.HintsPerPlayer),
|
||||
DropoutTiles: dropout,
|
||||
},
|
||||
Status: row.Status,
|
||||
GameID: row.GameID,
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
psel := postgres.SELECT(table.GameInvitationInvitees.AllColumns).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(id))).
|
||||
ORDER_BY(table.GameInvitationInvitees.Seat.ASC())
|
||||
var prows []model.GameInvitationInvitees
|
||||
if err := psel.QueryContext(ctx, s.db, &prows); err != nil {
|
||||
return Invitation{}, fmt.Errorf("lobby: load invitees %s: %w", id, err)
|
||||
}
|
||||
for _, p := range prows {
|
||||
inv.Invitees = append(inv.Invitees, Invitee{AccountID: p.AccountID, Seat: int(p.Seat), Response: p.Response})
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// respondTx applies an invitee's response inside a row-locked transaction so
|
||||
// concurrent responses serialise and exactly one accept can complete the set.
|
||||
func (s *Store) respondTx(ctx context.Context, invitationID, accountID uuid.UUID, accept bool, now time.Time) (respondResult, error) {
|
||||
var res respondResult
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
isel := postgres.SELECT(table.GameInvitations.Status, table.GameInvitations.ExpiresAt).
|
||||
FROM(table.GameInvitations).
|
||||
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID))).
|
||||
FOR(postgres.UPDATE())
|
||||
var inv model.GameInvitations
|
||||
if err := isel.QueryContext(ctx, tx, &inv); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return ErrInvitationNotFound
|
||||
}
|
||||
return fmt.Errorf("lock invitation: %w", err)
|
||||
}
|
||||
if inv.Status == invitationPending && now.After(inv.ExpiresAt) {
|
||||
if err := setInvitationStatus(ctx, tx, invitationID, invitationExpired, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrInvitationExpired
|
||||
}
|
||||
if inv.Status != invitationPending {
|
||||
return ErrInvitationNotPending
|
||||
}
|
||||
|
||||
psel := postgres.SELECT(table.GameInvitationInvitees.Response).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(
|
||||
table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
|
||||
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
|
||||
).LIMIT(1)
|
||||
var invitee model.GameInvitationInvitees
|
||||
if err := psel.QueryContext(ctx, tx, &invitee); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return ErrNotInvited
|
||||
}
|
||||
return fmt.Errorf("load invitee: %w", err)
|
||||
}
|
||||
if invitee.Response != inviteePending {
|
||||
return ErrAlreadyResponded
|
||||
}
|
||||
|
||||
if !accept {
|
||||
if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeDeclined, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return setInvitationStatus(ctx, tx, invitationID, invitationDeclined, now)
|
||||
}
|
||||
|
||||
if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeAccepted, now); err != nil {
|
||||
return err
|
||||
}
|
||||
remaining, err := unacceptedInvitees(ctx, tx, invitationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.allAccepted = remaining == 0
|
||||
return nil
|
||||
})
|
||||
return res, err
|
||||
}
|
||||
|
||||
// markStarted stamps a fully-accepted invitation as started, only while it is
|
||||
// still pending, and reports whether it did.
|
||||
func (s *Store) markStarted(ctx context.Context, invitationID, gameID uuid.UUID, now time.Time) (bool, error) {
|
||||
stmt := table.GameInvitations.
|
||||
UPDATE(table.GameInvitations.Status, table.GameInvitations.GameID, table.GameInvitations.UpdatedAt).
|
||||
SET(postgres.String(invitationStarted), postgres.UUID(gameID), postgres.TimestampzT(now)).
|
||||
WHERE(
|
||||
table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
|
||||
AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lobby: mark started: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lobby: mark started rows: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// cancelInvitation withdraws a pending invitation on behalf of its inviter.
|
||||
func (s *Store) cancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID, now time.Time) error {
|
||||
stmt := table.GameInvitations.
|
||||
UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
|
||||
SET(postgres.String(invitationCancelled), postgres.TimestampzT(now)).
|
||||
WHERE(
|
||||
table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
|
||||
AND(table.GameInvitations.InviterID.EQ(postgres.UUID(inviterID))).
|
||||
AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lobby: cancel invitation: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lobby: cancel invitation rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
// Either the invitation is gone, not the caller's, or no longer pending.
|
||||
inv, err := s.loadInvitation(ctx, invitationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inv.InviterID != inviterID {
|
||||
return ErrNotInviter
|
||||
}
|
||||
return ErrInvitationNotPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unacceptedInvitees counts the invitees of an invitation not yet accepted.
|
||||
func unacceptedInvitees(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID) (int, error) {
|
||||
stmt := postgres.SELECT(table.GameInvitationInvitees.Response).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)))
|
||||
var rows []model.GameInvitationInvitees
|
||||
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
return 0, fmt.Errorf("count invitees: %w", err)
|
||||
}
|
||||
remaining := 0
|
||||
for _, r := range rows {
|
||||
if r.Response != inviteeAccepted {
|
||||
remaining++
|
||||
}
|
||||
}
|
||||
return remaining, nil
|
||||
}
|
||||
|
||||
// setInvitationStatus updates an invitation's status and updated_at.
|
||||
func setInvitationStatus(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID, status string, now time.Time) error {
|
||||
stmt := table.GameInvitations.
|
||||
UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
|
||||
SET(postgres.String(status), postgres.TimestampzT(now)).
|
||||
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("set invitation status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setInviteeResponse updates one invitee's response and responded_at.
|
||||
func setInviteeResponse(ctx context.Context, tx *sql.Tx, invitationID, accountID uuid.UUID, response string, now time.Time) error {
|
||||
stmt := table.GameInvitationInvitees.
|
||||
UPDATE(table.GameInvitationInvitees.Response, table.GameInvitationInvitees.RespondedAt).
|
||||
SET(postgres.String(response), postgres.TimestampzT(now)).
|
||||
WHERE(
|
||||
table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
|
||||
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("set invitee response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||
// game. game.Service satisfies it.
|
||||
type GameCreator interface {
|
||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||
}
|
||||
|
||||
// Blocker reports whether two accounts have a block between them (either
|
||||
// direction). social.Service satisfies it; the lobby uses it to refuse
|
||||
// invitations between blocked accounts.
|
||||
type Blocker interface {
|
||||
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// Auto-match defaults: a casual two-player game on the longest move clock with one
|
||||
// hint per player (docs/ARCHITECTURE.md §6). The drop-out tile disposition is moot
|
||||
// for two players, so the engine default (remove) applies.
|
||||
const (
|
||||
autoMatchHintsAllowed = true
|
||||
autoMatchHintsPerPlayer = 1
|
||||
)
|
||||
|
||||
// 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")
|
||||
// ErrInvitationBlocked is returned when a block stands between the inviter and
|
||||
// an invitee.
|
||||
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
|
||||
// ErrInvitationNotFound is returned when no invitation matches the lookup.
|
||||
ErrInvitationNotFound = errors.New("lobby: invitation not found")
|
||||
// ErrInvitationNotPending is returned when an invitation is no longer open.
|
||||
ErrInvitationNotPending = errors.New("lobby: invitation is not pending")
|
||||
// ErrInvitationExpired is returned when an invitation has passed its deadline.
|
||||
ErrInvitationExpired = errors.New("lobby: invitation has expired")
|
||||
// ErrNotInvited is returned when an account is not an invitee of the invitation.
|
||||
ErrNotInvited = errors.New("lobby: account was not invited")
|
||||
// ErrAlreadyResponded is returned when an invitee has already accepted or declined.
|
||||
ErrAlreadyResponded = errors.New("lobby: invitee has already responded")
|
||||
// ErrNotInviter is returned when a non-inviter tries to cancel an invitation.
|
||||
ErrNotInviter = errors.New("lobby: only the inviter may cancel")
|
||||
)
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// fakeCreator records the games a matchmaker asks it to start.
|
||||
type fakeCreator struct {
|
||||
created []game.CreateParams
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) {
|
||||
if f.err != nil {
|
||||
return game.Game{}, f.err
|
||||
}
|
||||
f.created = append(f.created, p)
|
||||
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
||||
}
|
||||
|
||||
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 TestMatchmakerPairsTwoHumans(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator)
|
||||
ctx := context.Background()
|
||||
a, b := uuid.New(), uuid.New()
|
||||
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must wait, not match")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
|
||||
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerAlreadyQueued(t *testing.T) {
|
||||
mm := NewMatchmaker(&fakeCreator{})
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); !errors.Is(err, ErrAlreadyQueued) {
|
||||
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerCancel(t *testing.T) {
|
||||
mm := NewMatchmaker(&fakeCreator{})
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); 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 := NewMatchmaker(creator)
|
||||
ctx := context.Background()
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue en: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble); 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 := NewMatchmaker(creator)
|
||||
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); 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for friend-game invitations.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user