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 }