5b07bb4e14
Wires the gateway's signed SubscribeEvents stream end-to-end:
- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
current_turn advance, addressed to every active membership, push-only
channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
carries both per-event dispatch and revocation detection; toast
primitive (store + host) lives in lib/; GameStateStore gains
pendingTurn/markPendingTurn/advanceToPending and a persisted
lastViewedTurn so a return after multiple turns surfaces the same
"view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
(verifyPayloadHash + verifyEvent), full-jitter exponential backoff
1s -> 30s on transient failure, signOut("revoked") on
Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
(testcontainers + capturing publisher), consumer (Vitest event
stream, toast, game-state extensions), and a Playwright e2e
delivering a signed frame to the live UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
248 lines
7.8 KiB
Go
248 lines
7.8 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"
|
|
)
|
|
|
|
// 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
|
|
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.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
|
|
}
|