Files
galaxy-game/backend/internal/lobby/lobby.go
T
Ilia Denisov b3f24cc440
Tests · Go / test (push) Successful in 1m52s
Tests · Go / test (pull_request) Successful in 1m53s
Tests · Integration / integration (pull_request) Successful in 1m36s
diplomail (Stage B): admin/owner sends + lifecycle hooks
Item 7 of the spec wants game-state and membership-state changes to
land as durable inbox entries the affected players can re-read after
the fact — push alone times out of the 5-minute ring buffer. Stage B
adds the admin-kind send matrix (owner-driven via /user, site-admin
driven via /admin) plus the lobby lifecycle hooks: paused / cancelled
emit a broadcast system mail to active members, kick / ban emit a
single-recipient system mail to the affected user (which they keep
read access to even after the membership row is revoked, per item 8).

Migration relaxes diplomail_messages_kind_sender_chk so an owner
sending kind=admin keeps sender_kind=player; the new
LifecyclePublisher dep on lobby.Service is wired through a thin
adapter in cmd/backend/main, mirroring how lobby's notification
publisher is plumbed today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:54 +02:00

253 lines
8.0 KiB
Go

// 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
}