// Package lobby owns the platform-side game lifecycle of the Galaxy // `backend` service. It implements the substage 5.4 surface documented in // `backend/PLAN.md` §5.4 and `backend/README.md`: // // - Games CRUD with the enrollment/start/finish state machine. // - Applications, invites, and memberships with their lifecycles. // - Race Name Directory: registered, reservation, pending_registration // tiers with platform-wide canonical-key uniqueness. // - User-blocked and user-deleted cascades wired into `internal/user` // through the `LobbyCascade` interface. // - Inbound runtime hooks (`OnRuntimeSnapshot`, `OnGameFinished`) called // by `internal/runtime` once The implementation lands. // - A periodic sweeper goroutine that releases expired // `pending_registration` rows and auto-closes enrollment-expired // games. // // Stages 5.5 / 5.7 inject the real RuntimeGateway and // NotificationPublisher; until then `NewNoopRuntimeGateway` and // `NewNoopNotificationPublisher` keep the package callable end-to-end. package lobby import ( "crypto/rand" "encoding/hex" "errors" "fmt" "time" "galaxy/backend/internal/config" "github.com/jackc/pgx/v5/pgconn" "go.uber.org/zap" ) // pgErrCodeUniqueViolation is the SQLSTATE value Postgres emits on a // UNIQUE constraint violation. Duplicated from `internal/user` and // `internal/admin` so the lobby package does not import either. const pgErrCodeUniqueViolation = "23505" // pgErrCodeCheckViolation is the SQLSTATE value Postgres emits when a // CHECK constraint rejects a row. Used to map invalid status writes to // ErrInvalidInput at the boundary. const pgErrCodeCheckViolation = "23514" // inviteCodeBytes is the half-byte length of a generated invite code. // Each byte yields two hex characters, so the wire string is 16 chars. const inviteCodeBytes = 8 // Visibility values stored verbatim in `games.visibility`. const ( VisibilityPublic = "public" VisibilityPrivate = "private" ) // Game status vocabulary mirrors `games_status_chk` in // `backend/internal/postgres/migrations/00001_init.sql`. const ( GameStatusDraft = "draft" GameStatusEnrollmentOpen = "enrollment_open" GameStatusReadyToStart = "ready_to_start" GameStatusStarting = "starting" GameStatusStartFailed = "start_failed" GameStatusRunning = "running" GameStatusPaused = "paused" GameStatusFinished = "finished" GameStatusCancelled = "cancelled" ) // Application status vocabulary mirrors `applications_status_chk`. const ( ApplicationStatusPending = "pending" ApplicationStatusApproved = "approved" ApplicationStatusRejected = "rejected" ) // Invite status vocabulary mirrors `invites_status_chk`. const ( InviteStatusPending = "pending" InviteStatusRedeemed = "redeemed" InviteStatusDeclined = "declined" InviteStatusRevoked = "revoked" InviteStatusExpired = "expired" ) // Membership status vocabulary mirrors `memberships_status_chk`. const ( MembershipStatusActive = "active" MembershipStatusRemoved = "removed" MembershipStatusBlocked = "blocked" ) // Race-name status vocabulary mirrors `race_names_status_chk`. const ( RaceNameStatusRegistered = "registered" RaceNameStatusReservation = "reservation" RaceNameStatusPendingRegistration = "pending_registration" ) // Notification kinds emitted by lobby. Mirrors // `backend/README.md` §10, where the channel mapping is documented. const ( NotificationLobbyInviteReceived = "lobby.invite.received" NotificationLobbyInviteRevoked = "lobby.invite.revoked" NotificationLobbyApplicationSubmitted = "lobby.application.submitted" NotificationLobbyApplicationApproved = "lobby.application.approved" NotificationLobbyApplicationRejected = "lobby.application.rejected" NotificationLobbyMembershipRemoved = "lobby.membership.removed" NotificationLobbyMembershipBlocked = "lobby.membership.blocked" NotificationLobbyRaceNameRegistered = "lobby.race_name.registered" NotificationLobbyRaceNamePending = "lobby.race_name.pending" NotificationLobbyRaceNameExpired = "lobby.race_name.expired" NotificationGameTurnReady = "game.turn.ready" NotificationGamePaused = "game.paused" ) // Deps aggregates every collaborator the lobby Service depends on. // // Store and Cache are required. Logger and Now default to zap.NewNop / // time.Now when nil. Runtime, Notification, Entitlement and Policy fall // back to safe defaults (no-op publishers and a default-locale Policy) // so unit tests can construct a Service with only Store + Cache populated. type Deps struct { Store *Store Cache *Cache Runtime RuntimeGateway Notification NotificationPublisher Diplomail DiplomailPublisher Entitlement EntitlementProvider Policy *Policy Config config.LobbyConfig Logger *zap.Logger Now func() time.Time } // Service is the lobby-domain entry point. Every public method is // goroutine-safe; concurrency safety is delegated to Postgres for // persisted state and to `*Cache` for the in-memory projection. type Service struct { deps Deps } // NewService constructs a Service from deps. Logger and Now are // defaulted; Store and Cache must be non-nil — calling any method with // a nil Store/Cache will panic at first use (matching how main.go // signals missing wiring). func NewService(deps Deps) (*Service, error) { if deps.Logger == nil { deps.Logger = zap.NewNop() } deps.Logger = deps.Logger.Named("lobby") if deps.Now == nil { deps.Now = time.Now } if deps.Runtime == nil { deps.Runtime = NewNoopRuntimeGateway(deps.Logger) } if deps.Notification == nil { deps.Notification = NewNoopNotificationPublisher(deps.Logger) } if deps.Diplomail == nil { deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger) } if deps.Policy == nil { policy, err := NewPolicy() if err != nil { return nil, fmt.Errorf("lobby: build default race-name policy: %w", err) } deps.Policy = policy } if deps.Config.SweeperInterval <= 0 { deps.Config.SweeperInterval = 60 * time.Second } if deps.Config.PendingRegistrationTTL <= 0 { deps.Config.PendingRegistrationTTL = 30 * 24 * time.Hour } if deps.Config.InviteDefaultTTL <= 0 { deps.Config.InviteDefaultTTL = 7 * 24 * time.Hour } return &Service{deps: deps}, nil } // Logger exposes the named logger used by the service. Mainly useful for // tests asserting on log output. func (s *Service) Logger() *zap.Logger { if s == nil { return zap.NewNop() } return s.deps.Logger } // Cache returns the in-memory projection. Used by main.go for the // readiness probe and by tests. func (s *Service) Cache() *Cache { if s == nil { return nil } return s.deps.Cache } // Config returns the lobby-side runtime configuration. Used by the // sweeper to read the tick interval and by tests to assert the // pending-registration TTL. func (s *Service) Config() config.LobbyConfig { if s == nil { return config.LobbyConfig{} } return s.deps.Config } // generateInviteCode produces an `inviteCodeBytes`-byte hex code used // for code-based invites. The function uses `crypto/rand`; a failure to // read entropy is propagated to the caller. func generateInviteCode() (string, error) { buf := make([]byte, inviteCodeBytes) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("lobby: generate invite code: %w", err) } return hex.EncodeToString(buf), nil } // isUniqueViolation reports whether err is a Postgres UNIQUE violation, // optionally restricted to a specific constraint name. When // constraintName is empty any UNIQUE violation matches. func isUniqueViolation(err error, constraintName string) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } if pgErr.Code != pgErrCodeUniqueViolation { return false } if constraintName == "" { return true } return pgErr.ConstraintName == constraintName } // isCheckViolation reports whether err is a Postgres CHECK constraint // violation, optionally restricted to a specific constraint name. func isCheckViolation(err error, constraintName string) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } if pgErr.Code != pgErrCodeCheckViolation { return false } if constraintName == "" { return true } return pgErr.ConstraintName == constraintName }