feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+226
View File
@@ -0,0 +1,226 @@
package lobby
import (
"context"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SubmitApplicationInput is the parameter struct for
// Service.SubmitApplication.
type SubmitApplicationInput struct {
GameID uuid.UUID
ApplicantUserID uuid.UUID
RaceName string
}
// SubmitApplication creates a new application bound to (gameID,
// applicantUserID, raceName). The game must be `enrollment_open`. The
// race name is recorded for context but the per-game canonical
// reservation is created at approval time.
func (s *Service) SubmitApplication(ctx context.Context, in SubmitApplicationInput) (Application, error) {
displayName, err := ValidateDisplayName(in.RaceName)
if err != nil {
return Application{}, err
}
game, err := s.GetGame(ctx, in.GameID)
if err != nil {
return Application{}, err
}
if game.Visibility != VisibilityPublic {
return Application{}, fmt.Errorf("%w: only public games accept applications", ErrConflict)
}
if game.Status != GameStatusEnrollmentOpen {
return Application{}, fmt.Errorf("%w: game is not in enrollment_open", ErrConflict)
}
app, err := s.deps.Store.InsertApplication(ctx, applicationInsert{
ApplicationID: uuid.New(),
GameID: in.GameID,
ApplicantUserID: in.ApplicantUserID,
RaceName: displayName,
})
if err != nil {
return Application{}, err
}
intent := LobbyNotification{
Kind: NotificationLobbyApplicationSubmitted,
IdempotencyKey: "application:" + app.ApplicationID.String(),
Payload: map[string]any{
"game_id": game.GameID.String(),
"application_id": app.ApplicationID.String(),
},
}
if game.OwnerUserID != nil {
intent.Recipients = []uuid.UUID{*game.OwnerUserID}
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
// Notification failures never roll back the canonical write.
s.deps.Logger.Warn("application submitted notification failed",
zap.String("application_id", app.ApplicationID.String()),
zap.Error(pubErr))
}
return app, nil
}
// ApproveApplication transitions a pending application to `approved`,
// creates the matching membership, and reserves the race-name canonical
// in the Race Name Directory.
func (s *Service) ApproveApplication(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, applicationID uuid.UUID) (Application, error) {
app, err := s.deps.Store.LoadApplication(ctx, applicationID)
if err != nil {
return Application{}, err
}
if app.GameID != gameID {
return Application{}, ErrNotFound
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Application{}, err
}
if err := s.checkGameAdminOrOwner(game, callerUserID, callerIsAdmin); err != nil {
return Application{}, err
}
if app.Status != ApplicationStatusPending {
return Application{}, fmt.Errorf("%w: application status is %q", ErrConflict, app.Status)
}
if game.Status != GameStatusEnrollmentOpen {
return Application{}, fmt.Errorf("%w: game is not in enrollment_open", ErrConflict)
}
canonical, err := s.deps.Policy.Canonical(app.RaceName)
if err != nil {
return Application{}, err
}
if err := s.assertRaceNameAvailable(ctx, canonical, app.ApplicantUserID, gameID); err != nil {
return Application{}, err
}
now := s.deps.Now().UTC()
if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{
Name: app.RaceName,
Canonical: canonical,
Status: RaceNameStatusReservation,
OwnerUserID: app.ApplicantUserID,
GameID: gameID,
ReservedAt: &now,
}); err != nil {
return Application{}, err
}
membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{
MembershipID: uuid.New(),
GameID: gameID,
UserID: app.ApplicantUserID,
RaceName: app.RaceName,
CanonicalKey: canonical,
})
if err != nil {
// Best-effort cleanup of the race-name reservation if the
// membership insert lost the race; the cascade still records
// the rejection.
_ = s.deps.Store.DeleteRaceName(ctx, canonical, gameID)
return Application{}, err
}
updated, err := s.deps.Store.UpdateApplicationStatus(ctx, applicationID, ApplicationStatusApproved, now)
if err != nil {
return Application{}, err
}
s.deps.Cache.PutMembership(membership)
s.deps.Cache.PutRaceName(RaceNameEntry{
Name: app.RaceName,
Canonical: canonical,
Status: RaceNameStatusReservation,
OwnerUserID: app.ApplicantUserID,
GameID: gameID,
ReservedAt: &now,
})
intent := LobbyNotification{
Kind: NotificationLobbyApplicationApproved,
IdempotencyKey: "application-approved:" + applicationID.String(),
Recipients: []uuid.UUID{app.ApplicantUserID},
Payload: map[string]any{
"game_id": gameID.String(),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("application approved notification failed",
zap.String("application_id", updated.ApplicationID.String()),
zap.Error(pubErr))
}
return updated, nil
}
// RejectApplication transitions a pending application to `rejected`.
func (s *Service) RejectApplication(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, applicationID uuid.UUID) (Application, error) {
app, err := s.deps.Store.LoadApplication(ctx, applicationID)
if err != nil {
return Application{}, err
}
if app.GameID != gameID {
return Application{}, ErrNotFound
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Application{}, err
}
if err := s.checkGameAdminOrOwner(game, callerUserID, callerIsAdmin); err != nil {
return Application{}, err
}
if app.Status != ApplicationStatusPending {
return Application{}, fmt.Errorf("%w: application status is %q", ErrConflict, app.Status)
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateApplicationStatus(ctx, applicationID, ApplicationStatusRejected, now)
if err != nil {
return Application{}, err
}
intent := LobbyNotification{
Kind: NotificationLobbyApplicationRejected,
IdempotencyKey: "application-rejected:" + applicationID.String(),
Recipients: []uuid.UUID{app.ApplicantUserID},
Payload: map[string]any{
"game_id": gameID.String(),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("application rejected notification failed",
zap.String("application_id", updated.ApplicationID.String()),
zap.Error(pubErr))
}
return updated, nil
}
// ListMyApplications returns every application owned by userID.
func (s *Service) ListMyApplications(ctx context.Context, userID uuid.UUID) ([]Application, error) {
return s.deps.Store.ListMyApplications(ctx, userID)
}
// checkGameAdminOrOwner enforces that the caller is either an admin or
// (for private games) the owner. Public games admin-only — same rule as
// transition().
func (s *Service) checkGameAdminOrOwner(game GameRecord, callerUserID *uuid.UUID, callerIsAdmin bool) error {
return s.checkOwner(game, callerUserID, callerIsAdmin)
}
// assertRaceNameAvailable returns nil when canonical is free for
// userID inside gameID. Free means: no `registered` / `reservation` /
// `pending_registration` owned by anyone else.
func (s *Service) assertRaceNameAvailable(ctx context.Context, canonical CanonicalKey, userID, gameID uuid.UUID) error {
_ = gameID
rows, err := s.deps.Store.FindRaceNameByCanonical(ctx, canonical)
if err != nil {
return err
}
for _, r := range rows {
if r.OwnerUserID == userID {
// Same user already binds this canonical — the per-game PK
// handles same-game collisions, and a user is allowed to
// hold the same canonical across multiple active games.
continue
}
switch r.Status {
case RaceNameStatusRegistered, RaceNameStatusReservation, RaceNameStatusPendingRegistration:
return fmt.Errorf("%w: race name held by another user", ErrRaceNameTaken)
}
}
return nil
}
+285
View File
@@ -0,0 +1,285 @@
package lobby
import (
"context"
"fmt"
"sync"
"sync/atomic"
"github.com/google/uuid"
)
// Cache is the in-memory write-through projection of the active lobby
// state: games (any non-finished/non-cancelled status), per-game
// memberships, and the Race Name Directory canonical map.
//
// Reads (Get*) take RLocks; writes (Put*, Remove*) take Locks. The cache
// mirrors the `internal/auth.Cache`, `internal/user.Cache`, and
// `internal/admin.Cache` idioms — Postgres is the source of truth, the
// cache is updated only after a successful commit.
type Cache struct {
mu sync.RWMutex
games map[uuid.UUID]GameRecord
memberships map[uuid.UUID]map[uuid.UUID]Membership // game_id -> membership_id -> Membership
rnd map[CanonicalKey]RaceNameEntry // canonical -> latest entry (most recent write wins)
ready atomic.Bool
}
// NewCache constructs an empty Cache.
func NewCache() *Cache {
return &Cache{
games: make(map[uuid.UUID]GameRecord),
memberships: make(map[uuid.UUID]map[uuid.UUID]Membership),
rnd: make(map[CanonicalKey]RaceNameEntry),
}
}
// Warm fills the cache from store. Must be called once at process boot
// before the HTTP listener accepts traffic. Subsequent calls re-warm.
func (c *Cache) Warm(ctx context.Context, store *Store) error {
if c == nil {
return nil
}
games, err := store.ListAllGames(ctx)
if err != nil {
return fmt.Errorf("lobby cache warm: games: %w", err)
}
memberships, err := store.ListAllMemberships(ctx)
if err != nil {
return fmt.Errorf("lobby cache warm: memberships: %w", err)
}
raceNames, err := store.ListAllRaceNames(ctx)
if err != nil {
return fmt.Errorf("lobby cache warm: race names: %w", err)
}
c.mu.Lock()
defer c.mu.Unlock()
c.games = make(map[uuid.UUID]GameRecord, len(games))
for _, g := range games {
if isCacheableStatus(g.Status) {
c.games[g.GameID] = g
}
}
c.memberships = make(map[uuid.UUID]map[uuid.UUID]Membership, len(c.games))
for _, m := range memberships {
if _, ok := c.games[m.GameID]; !ok {
continue
}
bucket := c.memberships[m.GameID]
if bucket == nil {
bucket = make(map[uuid.UUID]Membership)
c.memberships[m.GameID] = bucket
}
bucket[m.MembershipID] = m
}
c.rnd = make(map[CanonicalKey]RaceNameEntry, len(raceNames))
for _, r := range raceNames {
c.rnd[r.Canonical] = r
}
c.ready.Store(true)
return nil
}
// Ready reports whether Warm completed at least once.
func (c *Cache) Ready() bool {
if c == nil {
return false
}
return c.ready.Load()
}
// Sizes returns the cardinalities of the three subordinate projections.
// Useful for the startup log line and tests.
func (c *Cache) Sizes() (games int, memberships int, raceNames int) {
if c == nil {
return 0, 0, 0
}
c.mu.RLock()
defer c.mu.RUnlock()
for _, b := range c.memberships {
memberships += len(b)
}
return len(c.games), memberships, len(c.rnd)
}
// GetGame returns the cached game record together with a presence flag.
// Misses always return the zero record and false. Note that a finished
// or cancelled game is not in the cache; callers fall back to the store
// when isCacheableStatus(...)==false at write time.
func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) {
if c == nil {
return GameRecord{}, false
}
c.mu.RLock()
defer c.mu.RUnlock()
g, ok := c.games[gameID]
return g, ok
}
// PutGame stores game in the cache when its status is cacheable;
// terminal statuses (finished, cancelled) cause the entry to be evicted.
func (c *Cache) PutGame(game GameRecord) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if !isCacheableStatus(game.Status) {
delete(c.games, game.GameID)
delete(c.memberships, game.GameID)
return
}
c.games[game.GameID] = game
}
// RemoveGame evicts the game and any cached memberships under it.
func (c *Cache) RemoveGame(gameID uuid.UUID) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.games, gameID)
delete(c.memberships, gameID)
}
// PutMembership stores or updates a membership row. Removes from cache
// when status is not active.
func (c *Cache) PutMembership(m Membership) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
bucket := c.memberships[m.GameID]
if m.Status != MembershipStatusActive {
if bucket != nil {
delete(bucket, m.MembershipID)
if len(bucket) == 0 {
delete(c.memberships, m.GameID)
}
}
return
}
if bucket == nil {
bucket = make(map[uuid.UUID]Membership)
c.memberships[m.GameID] = bucket
}
bucket[m.MembershipID] = m
}
// MembershipsForGame returns a copy of the active memberships for
// gameID. Empty when the game is not cached or has no active members.
func (c *Cache) MembershipsForGame(gameID uuid.UUID) []Membership {
if c == nil {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
bucket := c.memberships[gameID]
if len(bucket) == 0 {
return nil
}
out := make([]Membership, 0, len(bucket))
for _, m := range bucket {
out = append(out, m)
}
return out
}
// PutRaceName stores or updates a race-name entry keyed by canonical.
// The cache is best-effort — it serves uniqueness fast-paths but Postgres
// is the authoritative reader on contention.
func (c *Cache) PutRaceName(entry RaceNameEntry) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.rnd[entry.Canonical] = entry
}
// RemoveRaceName evicts the entry at canonical.
func (c *Cache) RemoveRaceName(canonical CanonicalKey) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.rnd, canonical)
}
// GetRaceName returns the cached entry plus a presence flag.
func (c *Cache) GetRaceName(canonical CanonicalKey) (RaceNameEntry, bool) {
if c == nil {
return RaceNameEntry{}, false
}
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.rnd[canonical]
return e, ok
}
// EvictUserMemberships removes every cached membership belonging to
// userID. Used by `OnUserBlocked` / `OnUserDeleted` after the cascade
// commits so the cache reflects the new persisted state.
func (c *Cache) EvictUserMemberships(userID uuid.UUID) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for gameID, bucket := range c.memberships {
for mid, m := range bucket {
if m.UserID == userID {
delete(bucket, mid)
}
}
if len(bucket) == 0 {
delete(c.memberships, gameID)
}
}
}
// EvictUserRaceNames removes every cached race-name owned by userID.
func (c *Cache) EvictUserRaceNames(userID uuid.UUID) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for k, e := range c.rnd {
if e.OwnerUserID == userID {
delete(c.rnd, k)
}
}
}
// EvictOwnerGames evicts every cached game whose owner is userID. Used
// after the cascade cancels the user's owned games.
func (c *Cache) EvictOwnerGames(userID uuid.UUID) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for gameID, g := range c.games {
if g.OwnerUserID != nil && *g.OwnerUserID == userID {
delete(c.games, gameID)
delete(c.memberships, gameID)
}
}
}
// isCacheableStatus reports whether the cache should hold a game with
// the supplied status. Terminal statuses (finished, cancelled) are
// evicted; the in-memory cache only reflects active state.
func isCacheableStatus(status string) bool {
switch status {
case GameStatusFinished, GameStatusCancelled:
return false
default:
return true
}
}
+122
View File
@@ -0,0 +1,122 @@
package lobby
import (
"testing"
"time"
"github.com/google/uuid"
)
func TestCachePutGetRemoveGame(t *testing.T) {
c := NewCache()
g := GameRecord{
GameID: uuid.New(),
Status: GameStatusEnrollmentOpen,
GameName: "Test Game",
CreatedAt: time.Now(),
}
if _, ok := c.GetGame(g.GameID); ok {
t.Fatalf("GetGame on empty cache returned ok=true")
}
c.PutGame(g)
got, ok := c.GetGame(g.GameID)
if !ok || got.GameID != g.GameID {
t.Fatalf("GetGame after PutGame: ok=%v, got=%v", ok, got)
}
c.RemoveGame(g.GameID)
if _, ok := c.GetGame(g.GameID); ok {
t.Fatalf("GetGame after RemoveGame: ok=true")
}
}
func TestCachePutGameEvictsOnTerminalStatus(t *testing.T) {
c := NewCache()
g := GameRecord{
GameID: uuid.New(),
Status: GameStatusEnrollmentOpen,
GameName: "Test Game",
}
c.PutGame(g)
if _, ok := c.GetGame(g.GameID); !ok {
t.Fatalf("PutGame did not insert")
}
g.Status = GameStatusFinished
c.PutGame(g)
if _, ok := c.GetGame(g.GameID); ok {
t.Fatalf("PutGame with finished did not evict")
}
}
func TestCachePutMembershipEvictsOnNonActive(t *testing.T) {
c := NewCache()
gameID := uuid.New()
c.PutGame(GameRecord{GameID: gameID, Status: GameStatusEnrollmentOpen})
m := Membership{
MembershipID: uuid.New(),
GameID: gameID,
UserID: uuid.New(),
Status: MembershipStatusActive,
}
c.PutMembership(m)
if got := c.MembershipsForGame(gameID); len(got) != 1 {
t.Fatalf("MembershipsForGame after add = %d, want 1", len(got))
}
m.Status = MembershipStatusRemoved
c.PutMembership(m)
if got := c.MembershipsForGame(gameID); len(got) != 0 {
t.Fatalf("MembershipsForGame after remove = %d, want 0", len(got))
}
}
func TestCachePutRaceNameAndEvict(t *testing.T) {
c := NewCache()
owner := uuid.New()
entry := RaceNameEntry{
Name: "Andromeda",
Canonical: CanonicalKey("andromeda"),
Status: RaceNameStatusReservation,
OwnerUserID: owner,
GameID: uuid.New(),
}
c.PutRaceName(entry)
got, ok := c.GetRaceName(entry.Canonical)
if !ok || got.Canonical != entry.Canonical {
t.Fatalf("GetRaceName: ok=%v, got=%v", ok, got)
}
c.EvictUserRaceNames(owner)
if _, ok := c.GetRaceName(entry.Canonical); ok {
t.Fatalf("EvictUserRaceNames did not evict")
}
}
func TestCacheReadyDefaultsFalse(t *testing.T) {
c := NewCache()
if c.Ready() {
t.Fatalf("Ready() before Warm = true, want false")
}
}
func TestCacheSizesZero(t *testing.T) {
c := NewCache()
games, members, raceNames := c.Sizes()
if games != 0 || members != 0 || raceNames != 0 {
t.Fatalf("Sizes() on empty = (%d,%d,%d), want (0,0,0)", games, members, raceNames)
}
}
func TestCacheEvictOwnerGames(t *testing.T) {
c := NewCache()
owner := uuid.New()
otherOwner := uuid.New()
owned := GameRecord{GameID: uuid.New(), Status: GameStatusEnrollmentOpen, OwnerUserID: &owner}
other := GameRecord{GameID: uuid.New(), Status: GameStatusEnrollmentOpen, OwnerUserID: &otherOwner}
c.PutGame(owned)
c.PutGame(other)
c.EvictOwnerGames(owner)
if _, ok := c.GetGame(owned.GameID); ok {
t.Fatalf("EvictOwnerGames did not evict owned game")
}
if _, ok := c.GetGame(other.GameID); !ok {
t.Fatalf("EvictOwnerGames evicted unrelated game")
}
}
+81
View File
@@ -0,0 +1,81 @@
package lobby
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
)
// OnUserBlocked releases every lobby binding owned by the user under
// the `blocked` semantics: active memberships flip to `blocked`,
// pending applications get rejected, pending invites incoming get
// declined / outgoing get revoked, race-name entries are deleted, and
// owned games in non-running statuses are cancelled.
//
// Implements `internal/user.LobbyCascade.OnUserBlocked`. Errors during
// the cascade are joined and returned but never roll back the
// already-committed user write — the canonical state is the row in
// Postgres.
func (s *Service) OnUserBlocked(ctx context.Context, userID uuid.UUID) error {
return s.runCascade(ctx, userID, MembershipStatusBlocked)
}
// OnUserDeleted runs the same cascade as OnUserBlocked but transitions
// memberships to `removed` instead of `blocked`. Implements
// `internal/user.LobbyCascade.OnUserDeleted`.
func (s *Service) OnUserDeleted(ctx context.Context, userID uuid.UUID) error {
return s.runCascade(ctx, userID, MembershipStatusRemoved)
}
func (s *Service) runCascade(ctx context.Context, userID uuid.UUID, membershipStatus string) error {
snap, err := s.deps.Store.LoadCascadeSnapshot(ctx, userID)
if err != nil {
return fmt.Errorf("lobby cascade: load snapshot: %w", err)
}
if snap.empty() {
return nil
}
now := s.deps.Now().UTC()
if err := s.deps.Store.CascadeUser(ctx, userID, snap, membershipStatus, now); err != nil {
return fmt.Errorf("lobby cascade: write: %w", err)
}
s.deps.Cache.EvictUserMemberships(userID)
s.deps.Cache.EvictUserRaceNames(userID)
s.deps.Cache.EvictOwnerGames(userID)
var notifyErrs []error
for _, gameID := range snap.OwnedGameIDs {
s.deps.Cache.RemoveGame(gameID)
}
if len(snap.ActiveMembershipIDs) > 0 {
intent := LobbyNotification{
Kind: NotificationLobbyMembershipRemoved,
IdempotencyKey: "user-cascade-membership:" + userID.String(),
Recipients: []uuid.UUID{userID},
Payload: map[string]any{
"reason": membershipStatus,
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
notifyErrs = append(notifyErrs, pubErr)
}
}
if len(notifyErrs) > 0 {
s.deps.Logger.Warn("lobby cascade notification failures",
zap.String("user_id", userID.String()),
zap.Int("notify_errors", len(notifyErrs)))
}
return errors.Join(notifyErrs...)
}
func (snap CascadeUserSnapshot) empty() bool {
return len(snap.OwnedGameIDs) == 0 &&
len(snap.ActiveMembershipIDs) == 0 &&
len(snap.PendingApplications) == 0 &&
len(snap.IncomingInvites) == 0 &&
len(snap.OutgoingInvites) == 0 &&
len(snap.RaceNameKeys) == 0
}
+125
View File
@@ -0,0 +1,125 @@
package lobby
import (
"context"
"github.com/google/uuid"
"go.uber.org/zap"
)
// EntitlementProvider is the read-only view the lobby needs over the
// user-domain entitlement snapshot. The canonical implementation is
// `*user.Service` exposing `GetEntitlement(ctx, userID)`; tests substitute
// a fake.
//
// `MaxRegisteredRaceNames` is the only field consumed by when
// the caller attempts to register a `pending_registration` row the lobby
// counts already-`registered` rows for that user against this limit.
type EntitlementProvider interface {
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
}
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
// module to start, pause, resume, or stop an engine container. The real
// implementation lives in `backend/internal/runtime` ; until
// then `NewNoopRuntimeGateway` ships a logger-only stub that pretends the
// request was accepted so the lobby state machine stays exercisable
// end-to-end.
type RuntimeGateway interface {
StartGame(ctx context.Context, gameID uuid.UUID) error
StopGame(ctx context.Context, gameID uuid.UUID) error
PauseGame(ctx context.Context, gameID uuid.UUID) error
ResumeGame(ctx context.Context, gameID uuid.UUID) error
}
// RuntimeJobResult is the inbound shape used by the runtime reconciler
// when a labelled container that lobby believes is alive has
// disappeared. The wiring connects `Service.OnRuntimeJobResult` against
// this type; the no-op consumer logs the event at debug level.
type RuntimeJobResult struct {
Op string
Status string
Message string
}
// NotificationPublisher is the outbound surface the lobby uses to fan out
// notification intents (invite received, application submitted, race name
// promoted, etc.). The real implementation lives in
// `backend/internal/notification` ; until then
// `NewNoopNotificationPublisher` ships a logger-only stub.
type NotificationPublisher interface {
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
}
// LobbyNotification is the open shape carried by a notification intent.
// The implementation emits a small set of `Kind` values matching the catalog in
// `backend/README.md` §10. The `Payload` map is the kind-specific data
// blob; recipients are the user_ids the intent should reach.
//
// The struct lives in the lobby package on purpose: it is the producer
// vocabulary. The implementation will reuse it as the notification.Submit input
// (or wrap it in a domain-side type, if more channels show up).
type LobbyNotification struct {
Kind string
IdempotencyKey string
Recipients []uuid.UUID
Payload map[string]any
}
// NewNoopRuntimeGateway returns a RuntimeGateway that logs every call at
// debug level and returns nil. The lobby state machine treats the no-op
// as "request was accepted asynchronously" — the game stays in `starting`
// until the canonical implementation wires real `runtime` / `OnRuntimeSnapshot` interactions.
func NewNoopRuntimeGateway(logger *zap.Logger) RuntimeGateway {
if logger == nil {
logger = zap.NewNop()
}
return &noopRuntimeGateway{logger: logger.Named("lobby.runtime.noop")}
}
type noopRuntimeGateway struct {
logger *zap.Logger
}
func (g *noopRuntimeGateway) StartGame(_ context.Context, gameID uuid.UUID) error {
g.logger.Debug("noop start-game", zap.String("game_id", gameID.String()))
return nil
}
func (g *noopRuntimeGateway) StopGame(_ context.Context, gameID uuid.UUID) error {
g.logger.Debug("noop stop-game", zap.String("game_id", gameID.String()))
return nil
}
func (g *noopRuntimeGateway) PauseGame(_ context.Context, gameID uuid.UUID) error {
g.logger.Debug("noop pause-game", zap.String("game_id", gameID.String()))
return nil
}
func (g *noopRuntimeGateway) ResumeGame(_ context.Context, gameID uuid.UUID) error {
g.logger.Debug("noop resume-game", zap.String("game_id", gameID.String()))
return nil
}
// NewNoopNotificationPublisher returns a NotificationPublisher that logs
// every call at debug level and returns nil. The implementation will swap in a
// real publisher backed by `notification.Submit`.
func NewNoopNotificationPublisher(logger *zap.Logger) NotificationPublisher {
if logger == nil {
logger = zap.NewNop()
}
return &noopNotificationPublisher{logger: logger.Named("lobby.notify.noop")}
}
type noopNotificationPublisher struct {
logger *zap.Logger
}
func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent LobbyNotification) error {
p.logger.Debug("noop notification",
zap.String("kind", intent.Kind),
zap.String("idempotency_key", intent.IdempotencyKey),
zap.Int("recipients", len(intent.Recipients)),
)
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package lobby
import "errors"
// Sentinel errors surface common rejection reasons across the lobby
// package. Handlers map them to HTTP envelopes through `respondLobbyError`
// in `internal/server/handlers_user_lobby_helpers.go`.
//
// Adding a new sentinel here is a deliberate API change: it appears in the
// handler error map and may surface as a new wire `code` value. Reuse the
// existing set when the behaviour overlaps.
var (
// ErrInvalidInput reports request-level validation failures (empty
// fields, malformed cron expressions, unknown enum values, race-name
// policy rejections). Maps to 400 invalid_request.
ErrInvalidInput = errors.New("lobby: invalid input")
// ErrNotFound reports that the requested record (game, application,
// invite, membership, race name) does not exist or is not visible to
// the caller. Maps to 404 not_found.
ErrNotFound = errors.New("lobby: not found")
// ErrForbidden reports that the caller is authenticated but not
// authorised for the requested action — most commonly "not the owner
// of this private game". Maps to 403 forbidden.
ErrForbidden = errors.New("lobby: forbidden")
// ErrConflict reports that the requested action conflicts with the
// current persisted state (illegal status transition, duplicate
// application, race-name canonical taken, invite already redeemed).
// Maps to 409 conflict.
ErrConflict = errors.New("lobby: conflict")
// ErrInvalidStatus reports a state-machine transition rejected by the
// game/application/invite/membership status. Treated as ErrConflict
// at the wire boundary; carried as a separate sentinel so transition
// callers can branch on it without parsing the wrapped message.
ErrInvalidStatus = errors.New("lobby: invalid status transition")
// ErrRaceNameTaken reports that a race-name canonical key is already
// claimed by a different user (registered, reserved, or
// pending_registration). Treated as ErrConflict at the wire boundary.
ErrRaceNameTaken = errors.New("lobby: race name is taken")
// ErrEntitlementExceeded reports that the caller already holds the
// maximum number of registered race names allowed by their tier.
// Treated as ErrConflict at the wire boundary.
ErrEntitlementExceeded = errors.New("lobby: entitlement quota exceeded")
// ErrPendingExpired reports that the pending_registration window
// passed before the user attempted to promote it to registered.
// Treated as ErrConflict at the wire boundary.
ErrPendingExpired = errors.New("lobby: pending registration expired")
)
+446
View File
@@ -0,0 +1,446 @@
package lobby
import (
"context"
"fmt"
"slices"
"strings"
"time"
"galaxy/cronutil"
"github.com/google/uuid"
)
// CreateGameInput is the parameter struct for Service.CreateGame.
type CreateGameInput struct {
OwnerUserID *uuid.UUID
Visibility string
GameName string
Description string
MinPlayers int32
MaxPlayers int32
StartGapHours int32
StartGapPlayers int32
EnrollmentEndsAt time.Time
TurnSchedule string
TargetEngineVersion string
}
// Validate normalises the request and rejects malformed values. It is
// called by Service.CreateGame before any Postgres write.
func (in *CreateGameInput) Validate(now time.Time) error {
in.GameName = strings.TrimSpace(in.GameName)
in.TurnSchedule = strings.TrimSpace(in.TurnSchedule)
in.TargetEngineVersion = strings.TrimSpace(in.TargetEngineVersion)
if in.GameName == "" {
return fmt.Errorf("%w: game_name must not be empty", ErrInvalidInput)
}
if in.Visibility != VisibilityPublic && in.Visibility != VisibilityPrivate {
return fmt.Errorf("%w: visibility must be 'public' or 'private'", ErrInvalidInput)
}
if in.Visibility == VisibilityPrivate && in.OwnerUserID == nil {
return fmt.Errorf("%w: private games require owner_user_id", ErrInvalidInput)
}
if in.Visibility == VisibilityPublic && in.OwnerUserID != nil {
return fmt.Errorf("%w: public games must not carry an owner_user_id", ErrInvalidInput)
}
if in.MinPlayers <= 0 || in.MaxPlayers <= 0 {
return fmt.Errorf("%w: min_players and max_players must be positive", ErrInvalidInput)
}
if in.MinPlayers > in.MaxPlayers {
return fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
}
if in.StartGapHours < 0 || in.StartGapPlayers < 0 {
return fmt.Errorf("%w: start_gap_hours and start_gap_players must be non-negative", ErrInvalidInput)
}
if in.EnrollmentEndsAt.Before(now) {
return fmt.Errorf("%w: enrollment_ends_at must be in the future", ErrInvalidInput)
}
if in.TurnSchedule == "" {
return fmt.Errorf("%w: turn_schedule must not be empty", ErrInvalidInput)
}
if _, err := cronutil.Parse(in.TurnSchedule); err != nil {
return fmt.Errorf("%w: turn_schedule must parse as a five-field cron expression: %v", ErrInvalidInput, err)
}
if in.TargetEngineVersion == "" {
return fmt.Errorf("%w: target_engine_version must not be empty", ErrInvalidInput)
}
return nil
}
// CreateGame persists a fresh `draft` game and returns it. The caller
// is responsible for setting OwnerUserID = nil (public games) or the
// authenticated user_id (private games).
func (s *Service) CreateGame(ctx context.Context, in CreateGameInput) (GameRecord, error) {
now := s.deps.Now().UTC()
if err := (&in).Validate(now); err != nil {
return GameRecord{}, err
}
rec, err := s.deps.Store.InsertGame(ctx, gameInsert{
GameID: uuid.New(),
OwnerUserID: in.OwnerUserID,
Visibility: in.Visibility,
GameName: in.GameName,
Description: in.Description,
MinPlayers: in.MinPlayers,
MaxPlayers: in.MaxPlayers,
StartGapHours: in.StartGapHours,
StartGapPlayers: in.StartGapPlayers,
EnrollmentEndsAt: in.EnrollmentEndsAt.UTC(),
TurnSchedule: in.TurnSchedule,
TargetEngineVersion: in.TargetEngineVersion,
})
if err != nil {
return GameRecord{}, err
}
s.deps.Cache.PutGame(rec)
return rec, nil
}
// UpdateGameInput is the parameter struct for Service.UpdateGame. Nil
// pointers leave the corresponding column alone.
type UpdateGameInput struct {
GameName *string
Description *string
EnrollmentEndsAt *time.Time
TurnSchedule *string
TargetEngineVersion *string
MinPlayers *int32
MaxPlayers *int32
StartGapHours *int32
StartGapPlayers *int32
}
// UpdateGame patches the supplied fields on a game. Only the owner of a
// private game (or admin via callerIsAdmin=true) can run this.
func (s *Service) UpdateGame(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID, in UpdateGameInput) (GameRecord, error) {
game, err := s.GetGame(ctx, gameID)
if err != nil {
return GameRecord{}, err
}
if err := s.checkOwner(game, callerUserID, callerIsAdmin); err != nil {
return GameRecord{}, err
}
now := s.deps.Now().UTC()
patch := gameUpdate{
Description: in.Description,
MinPlayers: in.MinPlayers,
MaxPlayers: in.MaxPlayers,
StartGapHours: in.StartGapHours,
StartGapPlayers: in.StartGapPlayers,
}
if in.GameName != nil {
trimmed := strings.TrimSpace(*in.GameName)
if trimmed == "" {
return GameRecord{}, fmt.Errorf("%w: game_name must not be empty", ErrInvalidInput)
}
patch.GameName = &trimmed
}
if in.TurnSchedule != nil {
trimmed := strings.TrimSpace(*in.TurnSchedule)
if trimmed == "" {
return GameRecord{}, fmt.Errorf("%w: turn_schedule must not be empty", ErrInvalidInput)
}
if _, err := cronutil.Parse(trimmed); err != nil {
return GameRecord{}, fmt.Errorf("%w: turn_schedule must parse: %v", ErrInvalidInput, err)
}
patch.TurnSchedule = &trimmed
}
if in.TargetEngineVersion != nil {
trimmed := strings.TrimSpace(*in.TargetEngineVersion)
if trimmed == "" {
return GameRecord{}, fmt.Errorf("%w: target_engine_version must not be empty", ErrInvalidInput)
}
patch.TargetEngineVersion = &trimmed
}
if in.EnrollmentEndsAt != nil {
t := in.EnrollmentEndsAt.UTC()
patch.EnrollmentEndsAt = &t
}
if patch.MinPlayers != nil && patch.MaxPlayers != nil && *patch.MinPlayers > *patch.MaxPlayers {
return GameRecord{}, fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
}
if patch.MinPlayers != nil && patch.MaxPlayers == nil && *patch.MinPlayers > game.MaxPlayers {
return GameRecord{}, fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
}
if patch.MaxPlayers != nil && patch.MinPlayers == nil && *patch.MaxPlayers < game.MinPlayers {
return GameRecord{}, fmt.Errorf("%w: max_players must not be less than min_players", ErrInvalidInput)
}
updated, err := s.deps.Store.UpdateGame(ctx, gameID, patch, now)
if err != nil {
return GameRecord{}, err
}
s.deps.Cache.PutGame(updated)
_ = now
return updated, nil
}
// GetGame returns the game record for gameID. Cache-first; falls back
// to Postgres on miss.
func (s *Service) GetGame(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
if rec, ok := s.deps.Cache.GetGame(gameID); ok {
return rec, nil
}
rec, err := s.deps.Store.LoadGame(ctx, gameID)
if err != nil {
return GameRecord{}, err
}
s.deps.Cache.PutGame(rec)
return rec, nil
}
// ListPublicGames returns the requested page of public games.
type GamePage struct {
Items []GameRecord
Page int
PageSize int
Total int
}
func (s *Service) ListPublicGames(ctx context.Context, page, pageSize int) (GamePage, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 50
}
games, total, err := s.deps.Store.ListPublicGames(ctx, page, pageSize)
if err != nil {
return GamePage{}, err
}
return GamePage{Items: games, Page: page, PageSize: pageSize, Total: total}, nil
}
// ListAdminGames returns the requested page of every game (admin view).
func (s *Service) ListAdminGames(ctx context.Context, page, pageSize int) (GamePage, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 50
}
games, total, err := s.deps.Store.ListAdminGames(ctx, page, pageSize)
if err != nil {
return GamePage{}, err
}
return GamePage{Items: games, Page: page, PageSize: pageSize, Total: total}, nil
}
// ListMyGames returns the games where the caller has an active
// membership.
func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord, error) {
return s.deps.Store.ListMyGames(ctx, userID)
}
// State-machine transition handlers below take the same shape: load the
// game (cache or store), check owner, validate the current status, run
// the transition write, refresh the cache, optionally tell the runtime
// gateway, and return the updated record.
// OpenEnrollment moves a `draft` game to `enrollment_open`.
func (s *Service) OpenEnrollment(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
From: []string{GameStatusDraft},
To: GameStatusEnrollmentOpen,
Reason: "open enrollment",
Notification: nil,
})
}
// ReadyToStart moves an `enrollment_open` game to `ready_to_start`. The
// transition succeeds only when the game has at least `min_players`
// active memberships.
func (s *Service) ReadyToStart(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
From: []string{GameStatusEnrollmentOpen},
To: GameStatusReadyToStart,
Reason: "ready to start",
Precondition: func(ctx context.Context, game GameRecord) error {
active, err := s.deps.Store.CountActiveMemberships(ctx, game.GameID)
if err != nil {
return err
}
if int32(active) < game.MinPlayers {
return fmt.Errorf("%w: approved_count (%d) must be >= min_players (%d)", ErrConflict, active, game.MinPlayers)
}
return nil
},
})
}
// Start kicks off the engine container; the lobby flips status to
// `starting` and asks RuntimeGateway. The implementation will transition the
// game to `running` via OnRuntimeSnapshot.
func (s *Service) Start(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
From: []string{GameStatusReadyToStart},
To: GameStatusStarting,
Reason: "start",
PostCommit: func(ctx context.Context, game GameRecord) error {
if err := s.deps.Runtime.StartGame(ctx, game.GameID); err != nil {
return fmt.Errorf("runtime start: %w", err)
}
return nil
},
})
}
// Pause moves a `running` game to `paused`.
func (s *Service) Pause(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
From: []string{GameStatusRunning},
To: GameStatusPaused,
Reason: "pause",
PostCommit: func(ctx context.Context, game GameRecord) error {
return s.deps.Runtime.PauseGame(ctx, game.GameID)
},
})
}
// Resume moves a `paused` game back to `running`.
func (s *Service) Resume(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
From: []string{GameStatusPaused},
To: GameStatusRunning,
Reason: "resume",
PostCommit: func(ctx context.Context, game GameRecord) error {
return s.deps.Runtime.ResumeGame(ctx, game.GameID)
},
})
}
// Cancel moves any non-terminal game to `cancelled`. The runtime is
// asked to stop a running container if any.
func (s *Service) Cancel(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
From: []string{
GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart,
GameStatusStarting, GameStatusStartFailed, GameStatusRunning, GameStatusPaused,
},
To: GameStatusCancelled,
Reason: "cancel",
PostCommit: func(ctx context.Context, game GameRecord) error {
switch game.Status {
case GameStatusRunning, GameStatusPaused, GameStatusStarting:
return s.deps.Runtime.StopGame(ctx, game.GameID)
}
return nil
},
})
}
// RetryStart moves a `start_failed` game back to `ready_to_start` so a
// subsequent /start call can re-attempt the runtime job.
func (s *Service) RetryStart(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
From: []string{GameStatusStartFailed},
To: GameStatusReadyToStart,
Reason: "retry start",
})
}
// AdminForceStart moves any pre-running game to `starting`, bypassing
// the owner-only and min_players precondition checks.
func (s *Service) AdminForceStart(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, nil, true, gameID, transitionRule{
From: []string{
GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart,
GameStatusStartFailed,
},
To: GameStatusStarting,
Reason: "admin force-start",
PostCommit: func(ctx context.Context, game GameRecord) error {
return s.deps.Runtime.StartGame(ctx, game.GameID)
},
})
}
// AdminForceStop moves a running/paused game to `cancelled`.
func (s *Service) AdminForceStop(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
return s.transition(ctx, nil, true, gameID, transitionRule{
From: []string{GameStatusRunning, GameStatusPaused, GameStatusStarting},
To: GameStatusCancelled,
Reason: "admin force-stop",
PostCommit: func(ctx context.Context, game GameRecord) error {
return s.deps.Runtime.StopGame(ctx, game.GameID)
},
})
}
// transitionRule captures the inputs to Service.transition so the
// per-handler code stays declarative. From is the set of statuses the
// transition accepts; To is the target status. Precondition runs
// before the write (e.g., approved_count >= min_players); PostCommit
// runs after a successful write/cache update (e.g., RuntimeGateway).
// Errors from PostCommit are joined into the returned error so the
// caller can decide whether to surface them; the canonical state
// remains the post-commit row.
type transitionRule struct {
From []string
To string
Reason string
Precondition func(ctx context.Context, game GameRecord) error
PostCommit func(ctx context.Context, game GameRecord) error
Notification *LobbyNotification
}
func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID, rule transitionRule) (GameRecord, error) {
game, err := s.GetGame(ctx, gameID)
if err != nil {
return GameRecord{}, err
}
if err := s.checkOwner(game, callerUserID, callerIsAdmin); err != nil {
return GameRecord{}, err
}
if !slices.Contains(rule.From, game.Status) {
return GameRecord{}, fmt.Errorf("%w: cannot %s game in status %q", ErrConflict, rule.Reason, game.Status)
}
if rule.Precondition != nil {
if err := rule.Precondition(ctx, game); err != nil {
return GameRecord{}, err
}
}
now := s.deps.Now().UTC()
upd := statusUpdate{NewStatus: rule.To, UpdatedAt: now}
switch rule.To {
case GameStatusRunning:
if game.StartedAt == nil {
upd.SetStarted = true
upd.StartedAt = now
}
case GameStatusFinished:
upd.SetFinished = true
upd.FinishedAt = now
}
updated, err := s.deps.Store.UpdateGameStatus(ctx, gameID, upd)
if err != nil {
return GameRecord{}, err
}
s.deps.Cache.PutGame(updated)
if rule.PostCommit != nil {
if err := rule.PostCommit(ctx, updated); err != nil {
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
}
}
return updated, nil
}
// checkOwner enforces ownership semantics:
//
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
// - private games → callerUserID must equal game.OwnerUserID.
// - public games → callerIsAdmin is required.
func (s *Service) checkOwner(game GameRecord, callerUserID *uuid.UUID, callerIsAdmin bool) error {
if callerIsAdmin {
return nil
}
if game.Visibility == VisibilityPublic {
return fmt.Errorf("%w: public games require admin authority", ErrForbidden)
}
if callerUserID == nil || game.OwnerUserID == nil || *game.OwnerUserID != *callerUserID {
return fmt.Errorf("%w: caller is not the owner", ErrForbidden)
}
return nil
}
+243
View File
@@ -0,0 +1,243 @@
package lobby
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// IssueInviteInput is the parameter struct for Service.IssueInvite.
type IssueInviteInput struct {
GameID uuid.UUID
InviterUserID uuid.UUID
InvitedUserID *uuid.UUID
RaceName string
ExpiresAt *time.Time
}
// IssueInvite creates a new pending invite. When InvitedUserID is set
// the invite is user-bound; otherwise the service generates a hex code
// for code-based redemption. The game must be a private game owned by
// inviterUserID and in `enrollment_open` (or `draft`/`ready_to_start`).
func (s *Service) IssueInvite(ctx context.Context, in IssueInviteInput) (Invite, error) {
game, err := s.GetGame(ctx, in.GameID)
if err != nil {
return Invite{}, err
}
if game.Visibility != VisibilityPrivate {
return Invite{}, fmt.Errorf("%w: only private games accept invites", ErrConflict)
}
if err := s.checkOwner(game, &in.InviterUserID, false); err != nil {
return Invite{}, err
}
switch game.Status {
case GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart:
default:
return Invite{}, fmt.Errorf("%w: cannot issue invite while game is %q", ErrConflict, game.Status)
}
displayName := strings.TrimSpace(in.RaceName)
if displayName != "" {
validated, err := ValidateDisplayName(displayName)
if err != nil {
return Invite{}, err
}
displayName = validated
}
now := s.deps.Now().UTC()
expires := now.Add(s.deps.Config.InviteDefaultTTL)
if in.ExpiresAt != nil {
expires = in.ExpiresAt.UTC()
}
if !expires.After(now) {
return Invite{}, fmt.Errorf("%w: expires_at must be in the future", ErrInvalidInput)
}
var code string
if in.InvitedUserID == nil {
generated, err := generateInviteCode()
if err != nil {
return Invite{}, err
}
code = generated
}
invite, err := s.deps.Store.InsertInvite(ctx, inviteInsert{
InviteID: uuid.New(),
GameID: in.GameID,
InviterUserID: in.InviterUserID,
InvitedUserID: in.InvitedUserID,
Code: code,
RaceName: displayName,
ExpiresAt: expires,
})
if err != nil {
return Invite{}, err
}
if in.InvitedUserID != nil {
intent := LobbyNotification{
Kind: NotificationLobbyInviteReceived,
IdempotencyKey: "invite-received:" + invite.InviteID.String(),
Recipients: []uuid.UUID{*in.InvitedUserID},
Payload: map[string]any{
"game_id": game.GameID.String(),
"inviter_user_id": in.InviterUserID.String(),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("invite issued notification failed",
zap.String("invite_id", invite.InviteID.String()),
zap.Error(pubErr))
}
}
return invite, nil
}
// RedeemInvite turns a pending invite into a membership for redeemerUserID.
// User-bound invites require the recipient to match
// `invited_user_id`; code-based invites accept any caller.
func (s *Service) RedeemInvite(ctx context.Context, redeemerUserID uuid.UUID, gameID, inviteID uuid.UUID) (Invite, error) {
invite, err := s.deps.Store.LoadInvite(ctx, inviteID)
if err != nil {
return Invite{}, err
}
if invite.GameID != gameID {
return Invite{}, ErrNotFound
}
if invite.Status != InviteStatusPending {
return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status)
}
now := s.deps.Now().UTC()
if !invite.ExpiresAt.After(now) {
return Invite{}, fmt.Errorf("%w: invite expired at %s", ErrConflict, invite.ExpiresAt.UTC().Format(time.RFC3339))
}
if invite.InvitedUserID != nil && *invite.InvitedUserID != redeemerUserID {
return Invite{}, fmt.Errorf("%w: invite is bound to a different user", ErrForbidden)
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Invite{}, err
}
switch game.Status {
case GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart:
default:
return Invite{}, fmt.Errorf("%w: cannot redeem invite while game is %q", ErrConflict, game.Status)
}
displayName := invite.RaceName
if displayName == "" {
return Invite{}, fmt.Errorf("%w: invite carries no race_name; ask issuer to re-issue", ErrInvalidInput)
}
canonical, err := s.deps.Policy.Canonical(displayName)
if err != nil {
return Invite{}, err
}
if err := s.assertRaceNameAvailable(ctx, canonical, redeemerUserID, gameID); err != nil {
return Invite{}, err
}
if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{
Name: displayName,
Canonical: canonical,
Status: RaceNameStatusReservation,
OwnerUserID: redeemerUserID,
GameID: gameID,
ReservedAt: &now,
}); err != nil {
return Invite{}, err
}
membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{
MembershipID: uuid.New(),
GameID: gameID,
UserID: redeemerUserID,
RaceName: displayName,
CanonicalKey: canonical,
})
if err != nil {
_ = s.deps.Store.DeleteRaceName(ctx, canonical, gameID)
return Invite{}, err
}
updated, err := s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusRedeemed, now)
if err != nil {
return Invite{}, err
}
s.deps.Cache.PutMembership(membership)
s.deps.Cache.PutRaceName(RaceNameEntry{
Name: displayName,
Canonical: canonical,
Status: RaceNameStatusReservation,
OwnerUserID: redeemerUserID,
GameID: gameID,
ReservedAt: &now,
})
return updated, nil
}
// DeclineInvite transitions a pending recipient-bound invite to
// `declined`. Code-based invites cannot be declined (the code holder
// just never redeems them).
func (s *Service) DeclineInvite(ctx context.Context, callerUserID uuid.UUID, gameID, inviteID uuid.UUID) (Invite, error) {
invite, err := s.deps.Store.LoadInvite(ctx, inviteID)
if err != nil {
return Invite{}, err
}
if invite.GameID != gameID {
return Invite{}, ErrNotFound
}
if invite.InvitedUserID == nil {
return Invite{}, fmt.Errorf("%w: code-based invites cannot be declined", ErrConflict)
}
if *invite.InvitedUserID != callerUserID {
return Invite{}, fmt.Errorf("%w: caller is not the invite recipient", ErrForbidden)
}
if invite.Status != InviteStatusPending {
return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status)
}
now := s.deps.Now().UTC()
return s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusDeclined, now)
}
// RevokeInvite transitions a pending invite to `revoked`. Only the
// inviter (or admin) may revoke.
func (s *Service) RevokeInvite(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, inviteID uuid.UUID) (Invite, error) {
invite, err := s.deps.Store.LoadInvite(ctx, inviteID)
if err != nil {
return Invite{}, err
}
if invite.GameID != gameID {
return Invite{}, ErrNotFound
}
if !callerIsAdmin {
if callerUserID == nil || invite.InviterUserID != *callerUserID {
return Invite{}, fmt.Errorf("%w: caller is not the inviter", ErrForbidden)
}
}
if invite.Status != InviteStatusPending {
return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status)
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusRevoked, now)
if err != nil {
return Invite{}, err
}
if invite.InvitedUserID != nil {
intent := LobbyNotification{
Kind: NotificationLobbyInviteRevoked,
IdempotencyKey: "invite-revoked:" + inviteID.String(),
Recipients: []uuid.UUID{*invite.InvitedUserID},
Payload: map[string]any{
"game_id": gameID.String(),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("invite revoked notification failed",
zap.String("invite_id", inviteID.String()),
zap.Error(pubErr))
}
}
return updated, nil
}
// ListMyInvites returns every invite where userID is the recipient.
func (s *Service) ListMyInvites(ctx context.Context, userID uuid.UUID) ([]Invite, error) {
return s.deps.Store.ListMyInvites(ctx, userID)
}
+246
View File
@@ -0,0 +1,246 @@
// 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"
)
// 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
}
+374
View File
@@ -0,0 +1,374 @@
package lobby_test
import (
"context"
"database/sql"
"errors"
"net/url"
"testing"
"time"
"galaxy/backend/internal/config"
"galaxy/backend/internal/lobby"
backendpg "galaxy/backend/internal/postgres"
pgshared "galaxy/postgres"
"github.com/google/uuid"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
testImage = "postgres:16-alpine"
testUser = "galaxy"
testPassword = "galaxy"
testDatabase = "galaxy_backend"
testSchema = "backend"
testStartup = 90 * time.Second
testOpTimeout = 10 * time.Second
)
func startPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
pgContainer, err := tcpostgres.Run(ctx, testImage,
tcpostgres.WithDatabase(testDatabase),
tcpostgres.WithUsername(testUser),
tcpostgres.WithPassword(testPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(testStartup),
),
)
if err != nil {
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
}
t.Cleanup(func() {
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
t.Errorf("terminate postgres container: %v", termErr)
}
})
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema)
if err != nil {
t.Fatalf("scope dsn: %v", err)
}
cfg := pgshared.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = testOpTimeout
db, err := pgshared.OpenPrimary(ctx, cfg)
if err != nil {
t.Fatalf("open primary: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close db: %v", err)
}
})
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
t.Fatalf("ping: %v", err)
}
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := parsed.Query()
values.Set("search_path", schema)
if values.Get("sslmode") == "" {
values.Set("sslmode", "disable")
}
parsed.RawQuery = values.Encode()
return parsed.String(), nil
}
type stubEntitlement struct {
max int32
}
func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUID) (int32, error) {
return s.max, nil
}
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
t.Helper()
store := lobby.NewStore(db)
cache := lobby.NewCache()
if err := cache.Warm(context.Background(), store); err != nil {
t.Fatalf("warm cache: %v", err)
}
svc, err := lobby.NewService(lobby.Deps{
Store: store,
Cache: cache,
Entitlement: stubEntitlement{max: max},
Config: config.LobbyConfig{
SweeperInterval: time.Second,
PendingRegistrationTTL: time.Hour,
InviteDefaultTTL: time.Hour,
},
Now: now,
})
if err != nil {
t.Fatalf("new service: %v", err)
}
return svc
}
// seedAccount inserts a minimal accounts row so games / memberships
// referencing user_id can be created without violating any FK.
func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) {
t.Helper()
_, err := db.ExecContext(context.Background(), `
INSERT INTO backend.accounts (
user_id, email, user_name, preferred_language, time_zone
) VALUES ($1, $2, $3, 'en', 'UTC')
`, userID, userID.String()+"@test.local", "user-"+userID.String()[:8])
if err != nil {
t.Fatalf("seed account %s: %v", userID, err)
}
}
func TestEndToEndPrivateGameFlow(t *testing.T) {
db := startPostgres(t)
now := time.Now().UTC()
clock := func() time.Time { return now }
svc := newServiceForTest(t, db, clock, 5)
owner := uuid.New()
seedAccount(t, db, owner)
game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
OwnerUserID: &owner,
Visibility: lobby.VisibilityPrivate,
GameName: "End-to-End Game",
MinPlayers: 1,
MaxPlayers: 4,
StartGapHours: 1,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(time.Hour),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "1.0.0",
})
if err != nil {
t.Fatalf("create game: %v", err)
}
if game.Status != lobby.GameStatusDraft {
t.Fatalf("create game status = %q, want draft", game.Status)
}
if got, ok := svc.Cache().GetGame(game.GameID); !ok || got.GameID != game.GameID {
t.Fatalf("game not cached after create")
}
if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("open enrollment: %v", err)
}
// Approve a member to clear min_players.
applicant := uuid.New()
seedAccount(t, db, applicant)
game = mustGet(t, svc, game.GameID)
// public-only handler does not run on private games; bypass via direct
// membership insert through the store to focus on state-machine.
store := lobby.NewStore(db)
canonicalPolicy, err := lobby.NewPolicy()
if err != nil {
t.Fatalf("new policy: %v", err)
}
canonical, err := canonicalPolicy.Canonical("PrivateRace")
if err != nil {
t.Fatalf("canonical: %v", err)
}
if _, err := db.ExecContext(context.Background(), `
INSERT INTO backend.memberships (
membership_id, game_id, user_id, race_name, canonical_key, status
) VALUES ($1, $2, $3, $4, $5, 'active')
`, uuid.New(), game.GameID, applicant, "PrivateRace", string(canonical)); err != nil {
t.Fatalf("seed membership: %v", err)
}
// Re-warm cache so the new membership flows through MembershipsForGame.
if err := svc.Cache().Warm(context.Background(), store); err != nil {
t.Fatalf("re-warm cache: %v", err)
}
if _, err := svc.ReadyToStart(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("ready-to-start: %v", err)
}
if _, err := svc.Start(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("start: %v", err)
}
game = mustGet(t, svc, game.GameID)
if game.Status != lobby.GameStatusStarting {
t.Fatalf("after start status = %q, want starting", game.Status)
}
// Simulate runtime → running.
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
CurrentTurn: 1,
RuntimeStatus: "running",
}); err != nil {
t.Fatalf("on-runtime-snapshot running: %v", err)
}
game = mustGet(t, svc, game.GameID)
if game.Status != lobby.GameStatusRunning {
t.Fatalf("after runtime snapshot status = %q, want running", game.Status)
}
if _, err := svc.Pause(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("pause: %v", err)
}
if _, err := svc.Resume(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("resume: %v", err)
}
if _, err := svc.Cancel(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("cancel: %v", err)
}
game, err = svc.GetGame(context.Background(), game.GameID)
if err != nil {
t.Fatalf("get cancelled: %v", err)
}
if game.Status != lobby.GameStatusCancelled {
t.Fatalf("after cancel status = %q, want cancelled", game.Status)
}
}
func TestEndToEndPublicGameApplicationApproval(t *testing.T) {
db := startPostgres(t)
now := time.Now().UTC()
clock := func() time.Time { return now }
svc := newServiceForTest(t, db, clock, 5)
game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
OwnerUserID: nil,
Visibility: lobby.VisibilityPublic,
GameName: "Public Game",
MinPlayers: 1,
MaxPlayers: 8,
StartGapHours: 1,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(time.Hour),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "1.0.0",
})
if err != nil {
t.Fatalf("create public game: %v", err)
}
// Move to enrollment_open via admin force-start path is wrong; use
// transition via admin OpenEnrollment by passing callerIsAdmin=true.
if _, err := svc.OpenEnrollment(context.Background(), nil, true, game.GameID); err != nil {
t.Fatalf("open enrollment (admin): %v", err)
}
applicant := uuid.New()
seedAccount(t, db, applicant)
app, err := svc.SubmitApplication(context.Background(), lobby.SubmitApplicationInput{
GameID: game.GameID,
ApplicantUserID: applicant,
RaceName: "AlphaCentauri",
})
if err != nil {
t.Fatalf("submit application: %v", err)
}
if app.Status != lobby.ApplicationStatusPending {
t.Fatalf("application status = %q, want pending", app.Status)
}
approved, err := svc.ApproveApplication(context.Background(), nil, true, game.GameID, app.ApplicationID)
if err != nil {
t.Fatalf("approve application: %v", err)
}
if approved.Status != lobby.ApplicationStatusApproved {
t.Fatalf("approved status = %q, want approved", approved.Status)
}
memberships, err := svc.ListMembershipsForGame(context.Background(), game.GameID)
if err != nil {
t.Fatalf("list memberships: %v", err)
}
if len(memberships) != 1 || memberships[0].UserID != applicant {
t.Fatalf("memberships = %+v, want one for %s", memberships, applicant)
}
// Re-applying the same race name from a different user must conflict.
other := uuid.New()
seedAccount(t, db, other)
_, err = svc.SubmitApplication(context.Background(), lobby.SubmitApplicationInput{
GameID: game.GameID,
ApplicantUserID: other,
RaceName: "AlphaCentauri",
})
if err != nil {
t.Fatalf("second application setup: %v", err)
}
if _, err := svc.ApproveApplication(context.Background(), nil, true, game.GameID, secondApplication(t, db, game.GameID, other)); err == nil {
t.Fatal("approving second application with same race name should conflict")
} else if !errors.Is(err, lobby.ErrRaceNameTaken) {
t.Fatalf("approve second application: err = %v, want ErrRaceNameTaken", err)
}
}
func TestSweeperReleasesExpiredPendingRegistrations(t *testing.T) {
db := startPostgres(t)
now := time.Now().UTC()
clock := func() time.Time { return now }
svc := newServiceForTest(t, db, clock, 5)
user := uuid.New()
seedAccount(t, db, user)
gameID := uuid.New()
expired := now.Add(-time.Hour)
if _, err := db.ExecContext(context.Background(), `
INSERT INTO backend.race_names (
name, canonical, status, owner_user_id, game_id, expires_at
) VALUES ('Vega', 'vega', 'pending_registration', $1, $2, $3)
`, user, gameID, expired); err != nil {
t.Fatalf("seed pending row: %v", err)
}
sweeper := lobby.NewSweeper(svc)
if err := sweeper.Tick(context.Background()); err != nil {
t.Fatalf("sweeper tick: %v", err)
}
rows, err := lobby.NewStore(db).FindRaceNameByCanonical(context.Background(), "vega")
if err != nil {
t.Fatalf("find canonical after sweep: %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected pending row to be released, got %d rows", len(rows))
}
}
func mustGet(t *testing.T, svc *lobby.Service, gameID uuid.UUID) lobby.GameRecord {
t.Helper()
g, err := svc.GetGame(context.Background(), gameID)
if err != nil {
t.Fatalf("get game %s: %v", gameID, err)
}
return g
}
// secondApplication looks up the second application id (the one
// submitted by `userID`) on `gameID`. The test seeds two applications
// in `TestEndToEndPublicGameApplicationApproval` and uses this helper
// to fetch the not-yet-decided one without coupling the test to insert
// order.
func secondApplication(t *testing.T, db *sql.DB, gameID, userID uuid.UUID) uuid.UUID {
t.Helper()
var id uuid.UUID
if err := db.QueryRowContext(context.Background(), `
SELECT application_id FROM backend.applications
WHERE game_id = $1 AND applicant_user_id = $2
`, gameID, userID).Scan(&id); err != nil {
t.Fatalf("lookup second application: %v", err)
}
return id
}
+160
View File
@@ -0,0 +1,160 @@
package lobby
import (
"context"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
)
// ListMembershipsForGame returns every membership row for gameID
// ordered by joined_at ASC. Reads always go to the store (the cache
// holds only active rows and would skip removed/blocked entries).
func (s *Service) ListMembershipsForGame(ctx context.Context, gameID uuid.UUID) ([]Membership, error) {
if _, err := s.GetGame(ctx, gameID); err != nil {
return nil, err
}
return s.deps.Store.ListMembershipsForGame(ctx, gameID)
}
// RemoveMembership transitions an active membership to `removed`. The
// caller must be the membership's user (self-leave) or the owner of
// the game (owner removal). Removing a membership releases its race
// name reservation in the same flow.
func (s *Service) RemoveMembership(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, membershipID uuid.UUID) (Membership, error) {
return s.changeMembershipStatus(ctx, callerUserID, callerIsAdmin, gameID, membershipID, MembershipStatusRemoved, NotificationLobbyMembershipRemoved, true)
}
// BlockMembership transitions an active membership to `blocked`. Only
// the owner of the game (or admin) may block.
func (s *Service) BlockMembership(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, membershipID uuid.UUID) (Membership, error) {
return s.changeMembershipStatus(ctx, callerUserID, callerIsAdmin, gameID, membershipID, MembershipStatusBlocked, NotificationLobbyMembershipBlocked, false)
}
// AdminBanMember is the admin-only variant of BlockMembership: targets
// a user_id directly (the request body carries it instead of a
// membership_id) and emits the same intent as BlockMembership.
func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (Membership, error) {
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Membership{}, err
}
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
if err != nil {
return Membership{}, err
}
var target Membership
found := false
for _, m := range memberships {
if m.UserID == userID && m.Status == MembershipStatusActive {
target = m
found = true
break
}
}
if !found {
return Membership{}, ErrNotFound
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateMembershipStatus(ctx, target.MembershipID, MembershipStatusBlocked, now)
if err != nil {
return Membership{}, err
}
s.deps.Cache.PutMembership(updated)
intent := LobbyNotification{
Kind: NotificationLobbyMembershipBlocked,
IdempotencyKey: "membership-blocked:" + updated.MembershipID.String(),
Recipients: []uuid.UUID{userID},
Payload: map[string]any{
"game_id": gameID.String(),
"reason": reason,
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("admin ban notification failed",
zap.String("membership_id", updated.MembershipID.String()),
zap.Error(pubErr))
}
_ = game
return updated, nil
}
// changeMembershipStatus is the shared implementation for Remove /
// Block. allowSelf controls whether the caller's own membership_id is
// an authorised target (true for Remove → "leave the game"; false for
// Block → owner-only).
func (s *Service) changeMembershipStatus(
ctx context.Context,
callerUserID *uuid.UUID,
callerIsAdmin bool,
gameID, membershipID uuid.UUID,
newStatus, notificationKind string,
allowSelf bool,
) (Membership, error) {
membership, err := s.deps.Store.LoadMembership(ctx, membershipID)
if err != nil {
return Membership{}, err
}
if membership.GameID != gameID {
return Membership{}, ErrNotFound
}
if membership.Status != MembershipStatusActive {
return Membership{}, fmt.Errorf("%w: membership is %q", ErrConflict, membership.Status)
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Membership{}, err
}
if !callerIsAdmin {
if !s.canManageMembership(game, membership, callerUserID, allowSelf) {
return Membership{}, fmt.Errorf("%w: caller is not authorised to manage this membership", ErrForbidden)
}
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateMembershipStatus(ctx, membershipID, newStatus, now)
if err != nil {
return Membership{}, err
}
s.deps.Cache.PutMembership(updated)
if newStatus != MembershipStatusActive {
// Release the race-name reservation tied to this game.
if err := s.deps.Store.DeleteRaceName(ctx, CanonicalKey(membership.CanonicalKey), gameID); err != nil {
s.deps.Logger.Warn("release race name on membership change failed",
zap.String("membership_id", membershipID.String()),
zap.String("canonical_key", membership.CanonicalKey),
zap.Error(err))
} else {
s.deps.Cache.RemoveRaceName(CanonicalKey(membership.CanonicalKey))
}
}
intent := LobbyNotification{
Kind: notificationKind,
IdempotencyKey: notificationKind + ":" + updated.MembershipID.String(),
Recipients: []uuid.UUID{updated.UserID},
Payload: map[string]any{
"game_id": gameID.String(),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("membership notification failed",
zap.String("membership_id", updated.MembershipID.String()),
zap.String("kind", notificationKind),
zap.Error(pubErr))
}
return updated, nil
}
func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool {
if game.Visibility == VisibilityPublic {
// Public-game membership management is admin-only.
return false
}
if game.OwnerUserID != nil && callerUserID != nil && *game.OwnerUserID == *callerUserID {
return true
}
if allowSelf && callerUserID != nil && membership.UserID == *callerUserID {
return true
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package lobby
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
confusables "github.com/disciplinedware/go-confusables"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// raceNameMaxRuneLen bounds the display length of a race-name. Must
// match the documented user-facing limit; the value is mirrored as an
// `if len(...)` check rather than enforced at the storage boundary so
// migrations stay simple.
const raceNameMaxRuneLen = 32
// CanonicalKey is the platform-wide race-name uniqueness key produced by
// `Policy.Canonical`. Two display names that yield the same CanonicalKey
// are considered the "same" race name for ownership purposes regardless
// of casing or visually-confusable substitutions.
type CanonicalKey string
// String returns the canonical key as its underlying string.
func (k CanonicalKey) String() string { return string(k) }
// IsZero reports whether the key carries no usable value.
func (k CanonicalKey) IsZero() bool { return strings.TrimSpace(string(k)) == "" }
// confusableSkeletoner is satisfied by the default
// `disciplinedware/go-confusables` runtime; tests substitute a
// deterministic stub via `WithSkeletoner`.
type confusableSkeletoner interface {
Skeleton(string) string
}
// Policy holds the canonicalisation pipeline used by the Race Name
// Directory. The pipeline is `case-fold → anti-fraud digit-letter
// replace → confusable skeleton`. Each step is idempotent.
type Policy struct {
caseFolder cases.Caser
skeletoner confusableSkeletoner
}
// antiFraudReplacer collapses the documented ASCII digit-to-letter
// pairs so `P1lot` and `Pilot` canonicalise to the same key. The set
// is intentionally small — adding entries broadens the equivalence
// classes platform-wide and is a deliberate policy decision.
var antiFraudReplacer = strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
)
// NewPolicy returns the default race-name canonicalisation policy.
// Returns an error when the `disciplinedware/go-confusables` default
// skeletoner cannot be obtained — should never happen in practice but
// the constructor surfaces it explicitly so tests can assert on
// failure.
func NewPolicy() (*Policy, error) {
p := &Policy{
caseFolder: cases.Fold(cases.Compact),
skeletoner: confusables.Default(),
}
if p.skeletoner == nil {
return nil, fmt.Errorf("lobby: build race-name policy: confusables.Default() returned nil")
}
return p, nil
}
// WithSkeletoner overrides the underlying TR39 confusable skeletoner.
// Tests use this to substitute a deterministic stub; production wiring
// uses the default obtained from `NewPolicy`.
func (p *Policy) WithSkeletoner(s confusableSkeletoner) *Policy {
if p == nil {
return nil
}
if s == nil {
return p
}
out := *p
out.skeletoner = s
return &out
}
// Canonical returns the canonical key for raceName. The function trims
// surrounding whitespace, applies Unicode case-folding, runs the
// anti-fraud replacer, and then computes the TR39 confusable skeleton.
// Returns ErrInvalidInput when raceName is empty after trimming or the
// resulting key is empty.
//
// `language.Und` is passed to the case folder because case-folding for
// race names is intentionally locale-independent — two players from
// different locales must agree on which names collide.
func (p *Policy) Canonical(raceName string) (CanonicalKey, error) {
if p == nil || p.skeletoner == nil {
return "", fmt.Errorf("%w: lobby policy not initialised", ErrInvalidInput)
}
trimmed := strings.TrimSpace(raceName)
if trimmed == "" {
return "", fmt.Errorf("%w: race name must not be empty", ErrInvalidInput)
}
if utf8.RuneCountInString(trimmed) > raceNameMaxRuneLen {
return "", fmt.Errorf("%w: race name exceeds %d characters", ErrInvalidInput, raceNameMaxRuneLen)
}
folded := p.caseFolder.String(trimmed)
mapped := antiFraudReplacer.Replace(folded)
skeleton := p.skeletoner.Skeleton(mapped)
if strings.TrimSpace(skeleton) == "" {
return "", fmt.Errorf("%w: race name canonical key is empty", ErrInvalidInput)
}
return CanonicalKey(skeleton), nil
}
// ValidateDisplayName enforces the structural invariants on the
// caller-supplied display form: non-empty, ≤ raceNameMaxRuneLen runes,
// no control characters. Returns the trimmed form on success.
func ValidateDisplayName(raceName string) (string, error) {
trimmed := strings.TrimSpace(raceName)
if trimmed == "" {
return "", fmt.Errorf("%w: race name must not be empty", ErrInvalidInput)
}
if utf8.RuneCountInString(trimmed) > raceNameMaxRuneLen {
return "", fmt.Errorf("%w: race name exceeds %d characters", ErrInvalidInput, raceNameMaxRuneLen)
}
for _, r := range trimmed {
if unicode.IsControl(r) {
return "", fmt.Errorf("%w: race name must not contain control characters", ErrInvalidInput)
}
}
return trimmed, nil
}
// languageForFolder is the static language tag passed to cases.Fold; it
// remains untyped at construction time and is resolved lazily inside
// `cases.Fold(...)`. Kept here so tests can reference it explicitly.
var languageForFolder = language.Und
+98
View File
@@ -0,0 +1,98 @@
package lobby
import (
"errors"
"strings"
"testing"
)
func TestPolicyCanonicalCaseFold(t *testing.T) {
policy := mustPolicy(t)
cases := []string{
"Andromeda",
"andromeda",
"ANDROMEDA",
" Andromeda ",
}
want, err := policy.Canonical(cases[0])
if err != nil {
t.Fatalf("baseline canonical: %v", err)
}
for _, c := range cases[1:] {
got, err := policy.Canonical(c)
if err != nil {
t.Fatalf("canonical %q: %v", c, err)
}
if got != want {
t.Errorf("canonical %q = %q, want %q", c, got, want)
}
}
}
func TestPolicyCanonicalAntiFraud(t *testing.T) {
policy := mustPolicy(t)
want, err := policy.Canonical("pilot")
if err != nil {
t.Fatalf("baseline canonical: %v", err)
}
for _, c := range []string{"P1lot", "p1lot", "p1L0T", "P1L0t"} {
got, err := policy.Canonical(c)
if err != nil {
t.Fatalf("canonical %q: %v", c, err)
}
if got != want {
t.Errorf("canonical %q = %q, want %q", c, got, want)
}
}
}
func TestPolicyCanonicalRejectsEmpty(t *testing.T) {
policy := mustPolicy(t)
_, err := policy.Canonical(" ")
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("canonical empty: err = %v, want ErrInvalidInput", err)
}
}
func TestPolicyCanonicalRejectsTooLong(t *testing.T) {
policy := mustPolicy(t)
long := strings.Repeat("a", 50)
_, err := policy.Canonical(long)
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("canonical too long: err = %v, want ErrInvalidInput", err)
}
}
func TestValidateDisplayNameRejectsControlChars(t *testing.T) {
if _, err := ValidateDisplayName("bad\x00name"); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("ValidateDisplayName control: err = %v, want ErrInvalidInput", err)
}
if _, err := ValidateDisplayName("good name"); err != nil {
t.Fatalf("ValidateDisplayName valid: err = %v", err)
}
}
func TestPolicyWithSkeletonerOverrides(t *testing.T) {
stub := stubSkeletoner(func(s string) string { return "fixed" })
policy := mustPolicy(t).WithSkeletoner(stub)
got, err := policy.Canonical("Andromeda")
if err != nil {
t.Fatalf("canonical with stub: %v", err)
}
if string(got) != "fixed" {
t.Errorf("canonical with stub = %q, want %q", got, "fixed")
}
}
func mustPolicy(t *testing.T) *Policy {
t.Helper()
p, err := NewPolicy()
if err != nil {
t.Fatalf("NewPolicy: %v", err)
}
return p
}
type stubSkeletoner func(string) string
func (s stubSkeletoner) Skeleton(in string) string { return s(in) }
@@ -0,0 +1,101 @@
package lobby
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
)
// RegisterRaceName promotes a `pending_registration` row owned by
// userID into a `registered` row. The promotion succeeds when:
//
// - the user has a `pending_registration` row matching the supplied
// display name (canonical key);
// - the row is still inside its 30-day window (expires_at > now);
// - the user owns fewer than `entitlement.max_registered_race_names`
// `registered` rows.
func (s *Service) RegisterRaceName(ctx context.Context, userID uuid.UUID, displayName string) (RaceNameEntry, error) {
displayName, err := ValidateDisplayName(displayName)
if err != nil {
return RaceNameEntry{}, err
}
canonical, err := s.deps.Policy.Canonical(displayName)
if err != nil {
return RaceNameEntry{}, err
}
rows, err := s.deps.Store.FindRaceNameByCanonical(ctx, canonical)
if err != nil {
return RaceNameEntry{}, err
}
var pending *RaceNameEntry
for i := range rows {
row := rows[i]
if row.OwnerUserID != userID {
if row.Status == RaceNameStatusRegistered ||
row.Status == RaceNameStatusReservation ||
row.Status == RaceNameStatusPendingRegistration {
return RaceNameEntry{}, fmt.Errorf("%w: race name held by another user", ErrRaceNameTaken)
}
continue
}
if row.Status == RaceNameStatusRegistered {
return RaceNameEntry{}, fmt.Errorf("%w: race name already registered by caller", ErrConflict)
}
if row.Status == RaceNameStatusPendingRegistration {
pending = &rows[i]
}
}
if pending == nil {
return RaceNameEntry{}, fmt.Errorf("%w: no pending_registration row for caller", ErrNotFound)
}
now := s.deps.Now().UTC()
if pending.ExpiresAt != nil && !pending.ExpiresAt.After(now) {
return RaceNameEntry{}, fmt.Errorf("%w: pending_registration window closed at %s", ErrPendingExpired, pending.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z07:00"))
}
maxAllowed := int32(1)
if s.deps.Entitlement != nil {
got, eerr := s.deps.Entitlement.GetMaxRegisteredRaceNames(ctx, userID)
if eerr != nil {
return RaceNameEntry{}, fmt.Errorf("lobby: read entitlement: %w", eerr)
}
maxAllowed = got
}
currentCount, err := s.deps.Store.CountRegisteredRaceNamesByUser(ctx, userID)
if err != nil {
return RaceNameEntry{}, err
}
if int32(currentCount) >= maxAllowed {
return RaceNameEntry{}, fmt.Errorf("%w: %d registered race names of %d allowed", ErrEntitlementExceeded, currentCount, maxAllowed)
}
entry, err := s.deps.Store.PromotePendingToRegistered(ctx, canonical, userID, pending.GameID, displayName, now)
if err != nil {
if errors.Is(err, ErrNotFound) {
return RaceNameEntry{}, fmt.Errorf("%w: pending row vanished concurrently", ErrConflict)
}
return RaceNameEntry{}, err
}
s.deps.Cache.RemoveRaceName(canonical)
s.deps.Cache.PutRaceName(entry)
intent := LobbyNotification{
Kind: NotificationLobbyRaceNameRegistered,
IdempotencyKey: "racename-registered:" + string(canonical),
Recipients: []uuid.UUID{userID},
Payload: map[string]any{
"race_name": displayName,
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("race-name registered notification failed",
zap.String("canonical", string(canonical)),
zap.Error(pubErr))
}
return entry, nil
}
// ListMyRaceNames returns every race-name row owned by userID.
func (s *Service) ListMyRaceNames(ctx context.Context, userID uuid.UUID) ([]RaceNameEntry, error) {
return s.deps.Store.ListRaceNamesForUser(ctx, userID)
}
+275
View File
@@ -0,0 +1,275 @@
package lobby
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// OnRuntimeSnapshot updates the denormalised runtime view on the game
// row from a snapshot reported by the runtime module. The lobby
// transitions the game's lifecycle status when the snapshot reports a
// state change relevant to the lobby state machine:
//
// - `running` → `running` (after `starting`).
// - `engine_unreachable` / `start_failed` → `start_failed` while
// `starting`.
// - `finished` → triggers `OnGameFinished`.
//
// Per-player MaxPlanets / MaxPopulation are accumulated across the
// game lifetime so the capable-finish evaluation in `OnGameFinished`
// has the data it needs.
//
// The current implementation ships the entry point + state-machine logic; The implementation // (runtime) wires the actual call site.
func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snapshot RuntimeSnapshot) error {
game, err := s.GetGame(ctx, gameID)
if err != nil {
return err
}
merged := mergeRuntimeSnapshot(game.RuntimeSnapshot, snapshot)
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateGameRuntimeSnapshot(ctx, gameID, merged, now)
if err != nil {
return err
}
if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition {
switch next {
case GameStatusFinished:
s.deps.Cache.PutGame(updated)
return s.OnGameFinished(ctx, gameID)
default:
rec, err := s.deps.Store.UpdateGameStatus(ctx, gameID, statusUpdate{
NewStatus: next,
UpdatedAt: now,
SetStarted: next == GameStatusRunning && updated.StartedAt == nil,
StartedAt: now,
})
if err != nil {
return err
}
updated = rec
}
}
s.deps.Cache.PutGame(updated)
return nil
}
// OnGameFinished completes the game lifecycle: marks the game as
// `finished`, evaluates capable-finish per active member, and
// transitions reservation rows to either `pending_registration`
// (capable) or deletes them (non-capable).
func (s *Service) OnGameFinished(ctx context.Context, gameID uuid.UUID) error {
game, err := s.GetGame(ctx, gameID)
if err != nil {
return err
}
now := s.deps.Now().UTC()
if game.Status != GameStatusFinished {
updated, err := s.deps.Store.UpdateGameStatus(ctx, gameID, statusUpdate{
NewStatus: GameStatusFinished,
UpdatedAt: now,
SetFinished: true,
FinishedAt: now,
})
if err != nil {
return err
}
game = updated
}
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
if err != nil {
return err
}
statsByUser := make(map[uuid.UUID]PlayerTurnStats, len(game.RuntimeSnapshot.PlayerStats))
for _, st := range game.RuntimeSnapshot.PlayerStats {
statsByUser[st.UserID] = st
}
expiry := now.Add(s.deps.Config.PendingRegistrationTTL)
var promoteErrs []error
for _, m := range memberships {
if m.Status != MembershipStatusActive {
continue
}
stats, hasStats := statsByUser[m.UserID]
canonical := CanonicalKey(m.CanonicalKey)
if hasStats && capableFinish(stats) {
// Best-effort: drop the existing reservation row before
// inserting the pending_registration so the per-game PK
// does not block the transition.
if err := s.deps.Store.DeleteRaceName(ctx, canonical, gameID); err != nil {
promoteErrs = append(promoteErrs, fmt.Errorf("delete reservation %s: %w", canonical, err))
continue
}
s.deps.Cache.RemoveRaceName(canonical)
entry, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{
Name: m.RaceName,
Canonical: canonical,
Status: RaceNameStatusPendingRegistration,
OwnerUserID: m.UserID,
GameID: gameID,
SourceGameID: ptrUUID(gameID),
ExpiresAt: &expiry,
})
if err != nil {
promoteErrs = append(promoteErrs, fmt.Errorf("promote pending %s: %w", canonical, err))
continue
}
s.deps.Cache.PutRaceName(entry)
intent := LobbyNotification{
Kind: NotificationLobbyRaceNamePending,
IdempotencyKey: "racename-pending:" + string(canonical) + ":" + gameID.String(),
Recipients: []uuid.UUID{m.UserID},
Payload: map[string]any{
"race_name": m.RaceName,
"expires_at": expiry.Format(time.RFC3339),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("race-name pending notification failed",
zap.String("canonical", string(canonical)),
zap.Error(pubErr))
}
continue
}
if err := s.deps.Store.DeleteRaceName(ctx, canonical, gameID); err != nil {
promoteErrs = append(promoteErrs, fmt.Errorf("delete non-capable reservation %s: %w", canonical, err))
continue
}
s.deps.Cache.RemoveRaceName(canonical)
}
s.deps.Cache.PutGame(game)
return errors.Join(promoteErrs...)
}
// OnRuntimeJobResult consumes adoption / removal events emitted by the
// runtime reconciler. The wiring connects the runtime → lobby callback
// through this entry point; the canonical mapping is:
//
// - reconciler reports `removed` → lobby cancels the game (the
// engine container is gone). Games already in `cancelled` or
// `finished` are ignored.
//
// Future job paths (start, stop, restart) may reuse the same shape.
func (s *Service) OnRuntimeJobResult(ctx context.Context, gameID uuid.UUID, result RuntimeJobResult) error {
if s == nil {
return nil
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil
}
return err
}
if game.Status == GameStatusCancelled || game.Status == GameStatusFinished {
return nil
}
if result.Status != "removed" && result.Status != "stopped" {
// Unknown status — ignore for forward compatibility.
return nil
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateGameStatus(ctx, gameID, statusUpdate{
NewStatus: GameStatusCancelled,
UpdatedAt: now,
})
if err != nil {
return err
}
s.deps.Cache.PutGame(updated)
s.deps.Logger.Info("game cancelled by runtime reconciler",
zap.String("game_id", gameID.String()),
zap.String("op", result.Op),
zap.String("status", result.Status),
zap.String("message", result.Message),
)
return nil
}
// mergeRuntimeSnapshot merges the incoming snapshot into the previous
// one, preserving running maxima of per-player planets and population
// across the game lifetime.
func mergeRuntimeSnapshot(prev, next RuntimeSnapshot) RuntimeSnapshot {
out := RuntimeSnapshot{
CurrentTurn: next.CurrentTurn,
RuntimeStatus: next.RuntimeStatus,
EngineHealth: next.EngineHealth,
ObservedAt: next.ObservedAt,
}
statsByUser := make(map[uuid.UUID]PlayerTurnStats, len(prev.PlayerStats)+len(next.PlayerStats))
for _, st := range prev.PlayerStats {
statsByUser[st.UserID] = st
}
for _, st := range next.PlayerStats {
existing, ok := statsByUser[st.UserID]
if !ok {
st.MaxPlanets = max32(st.MaxPlanets, st.CurrentPlanets)
st.MaxPopulation = max32(st.MaxPopulation, st.CurrentPopulation)
statsByUser[st.UserID] = st
continue
}
st.InitialPlanets = existing.InitialPlanets
st.InitialPopulation = existing.InitialPopulation
st.MaxPlanets = max32(existing.MaxPlanets, max32(st.MaxPlanets, st.CurrentPlanets))
st.MaxPopulation = max32(existing.MaxPopulation, max32(st.MaxPopulation, st.CurrentPopulation))
statsByUser[st.UserID] = st
}
if len(statsByUser) > 0 {
out.PlayerStats = make([]PlayerTurnStats, 0, len(statsByUser))
for _, st := range statsByUser {
out.PlayerStats = append(out.PlayerStats, st)
}
}
return out
}
// nextStatusFromSnapshot maps the runtime-reported runtime status into
// a lobby status transition. Returns (next, true) when the lobby
// status must change; (current, false) otherwise.
func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) {
switch snapshot.RuntimeStatus {
case "running":
if currentStatus == GameStatusStarting {
return GameStatusRunning, true
}
case "engine_unreachable", "start_failed", "generation_failed":
if currentStatus == GameStatusStarting {
return GameStatusStartFailed, true
}
case "finished":
if currentStatus != GameStatusFinished && currentStatus != GameStatusCancelled {
return GameStatusFinished, true
}
case "stopped":
if currentStatus == GameStatusRunning || currentStatus == GameStatusPaused {
return GameStatusFinished, true
}
}
return currentStatus, false
}
// capableFinish reports whether a per-player observation satisfies the
// "capable finish" criterion documented in
// `backend/PLAN.md` §5.4: max_planets > initial AND max_population >
// initial. Either of the inputs being zero (no observation) defaults
// to non-capable.
func capableFinish(stats PlayerTurnStats) bool {
if stats.InitialPlanets == 0 || stats.InitialPopulation == 0 {
return false
}
return stats.MaxPlanets > stats.InitialPlanets &&
stats.MaxPopulation > stats.InitialPopulation
}
func max32(a, b int32) int32 {
if a > b {
return a
}
return b
}
func ptrUUID(u uuid.UUID) *uuid.UUID { v := u; return &v }
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
package lobby
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// Sweeper is the periodic lobby maintenance worker. Each tick it
// releases expired `pending_registration` race-name rows and
// auto-closes enrollment windows whose `enrollment_ends_at` has passed.
//
// Implements `internal/app.Component`. The sweeper Run loop terminates
// on the parent context cancellation; Shutdown is a no-op because
// every tick already completes synchronously inside Run.
type Sweeper struct {
svc *Service
interval time.Duration
logger *zap.Logger
now func() time.Time
}
// NewSweeper constructs the sweeper. The interval falls back to the
// service config when zero.
func NewSweeper(svc *Service) *Sweeper {
cfg := svc.Config()
return &Sweeper{
svc: svc,
interval: cfg.SweeperInterval,
logger: svc.Logger().Named("sweeper"),
now: svc.deps.Now,
}
}
// Run drives the sweeper goroutine until ctx is done.
func (s *Sweeper) Run(ctx context.Context) error {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
// Run one tick immediately so a fresh process catches up on missed
// work without waiting for the first interval. Tests rely on this
// for deterministic e2e flows.
if err := s.tick(ctx); err != nil {
s.logger.Warn("lobby sweeper tick failed", zap.Error(err))
}
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := s.tick(ctx); err != nil {
s.logger.Warn("lobby sweeper tick failed", zap.Error(err))
}
}
}
}
// Shutdown is a no-op: every tick is synchronous inside Run.
func (s *Sweeper) Shutdown(_ context.Context) error { return nil }
// Tick runs a single sweep iteration. Exposed for tests so they can
// drive the sweeper without timing dependencies.
func (s *Sweeper) Tick(ctx context.Context) error { return s.tick(ctx) }
func (s *Sweeper) tick(ctx context.Context) error {
now := s.now().UTC()
releaseErr := s.releaseExpiredPending(ctx, now)
closeErr := s.autoCloseEnrollment(ctx, now)
return errors.Join(releaseErr, closeErr)
}
func (s *Sweeper) releaseExpiredPending(ctx context.Context, now time.Time) error {
rows, err := s.svc.deps.Store.ListPendingRegistrationsExpired(ctx, now)
if err != nil {
return fmt.Errorf("lobby sweeper: list expired pending: %w", err)
}
var errs []error
for _, row := range rows {
if err := s.svc.deps.Store.DeleteRaceName(ctx, row.Canonical, row.GameID); err != nil {
errs = append(errs, fmt.Errorf("delete pending %s: %w", row.Canonical, err))
continue
}
s.svc.deps.Cache.RemoveRaceName(row.Canonical)
intent := LobbyNotification{
Kind: NotificationLobbyRaceNameExpired,
IdempotencyKey: "racename-expired:" + string(row.Canonical) + ":" + row.GameID.String(),
Recipients: []uuid.UUID{row.OwnerUserID},
Payload: map[string]any{
"race_name": row.Name,
},
}
if pubErr := s.svc.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.logger.Warn("expired notification failed",
zap.String("canonical", string(row.Canonical)),
zap.Error(pubErr))
}
}
return errors.Join(errs...)
}
func (s *Sweeper) autoCloseEnrollment(ctx context.Context, now time.Time) error {
games, err := s.svc.deps.Store.ListEnrollmentExpiredGames(ctx, now)
if err != nil {
return fmt.Errorf("lobby sweeper: list expired enrollments: %w", err)
}
var errs []error
for _, game := range games {
active, err := s.svc.deps.Store.CountActiveMemberships(ctx, game.GameID)
if err != nil {
errs = append(errs, fmt.Errorf("count memberships %s: %w", game.GameID, err))
continue
}
if int32(active) < game.MinPlayers {
// Below quorum — leave the game in enrollment_open. Admins
// can extend `enrollment_ends_at` or cancel manually.
s.logger.Debug("enrollment expired below quorum, leaving",
zap.String("game_id", game.GameID.String()),
zap.Int32("min_players", game.MinPlayers),
zap.Int("active", active))
continue
}
updated, err := s.svc.deps.Store.UpdateGameStatus(ctx, game.GameID, statusUpdate{
NewStatus: GameStatusReadyToStart,
UpdatedAt: now,
})
if err != nil {
errs = append(errs, fmt.Errorf("transition %s to ready_to_start: %w", game.GameID, err))
continue
}
s.svc.deps.Cache.PutGame(updated)
s.logger.Info("enrollment auto-closed",
zap.String("game_id", game.GameID.String()),
zap.Int32("min_players", game.MinPlayers),
zap.Int("active", active))
}
return errors.Join(errs...)
}
+137
View File
@@ -0,0 +1,137 @@
package lobby
import (
"time"
"github.com/google/uuid"
)
// GameRecord mirrors a row in `backend.games` enriched with the
// denormalised runtime snapshot fields persisted in the same row. The
// JSON-encoded `runtime_snapshot` column is decoded into RuntimeSnapshot
// before reaching this struct.
type GameRecord struct {
GameID uuid.UUID
OwnerUserID *uuid.UUID
Visibility string
Status string
GameName string
Description string
MinPlayers int32
MaxPlayers int32
StartGapHours int32
StartGapPlayers int32
EnrollmentEndsAt time.Time
TurnSchedule string
TargetEngineVersion string
RuntimeSnapshot RuntimeSnapshot
CreatedAt time.Time
UpdatedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
}
// RuntimeSnapshot is the lobby's denormalised view of the runtime state
// reported by the runtime module. The current implementation ships placeholder values
// (zero CurrentTurn, empty RuntimeStatus) until the canonical implementation wires
// `OnRuntimeSnapshot`.
type RuntimeSnapshot struct {
CurrentTurn int32 `json:"current_turn"`
RuntimeStatus string `json:"runtime_status,omitempty"`
EngineHealth string `json:"engine_health,omitempty"`
PlayerStats []PlayerTurnStats `json:"player_stats,omitempty"`
ObservedAt time.Time `json:"observed_at,omitempty"`
}
// PlayerTurnStats is the per-player observation read from a runtime
// snapshot. Lobby aggregates `MaxPlanets` / `MaxPopulation` across the
// game lifetime to evaluate capable-finish at `OnGameFinished`.
type PlayerTurnStats struct {
UserID uuid.UUID `json:"user_id"`
InitialPlanets int32 `json:"initial_planets"`
InitialPopulation int32 `json:"initial_population"`
CurrentPlanets int32 `json:"current_planets"`
CurrentPopulation int32 `json:"current_population"`
MaxPlanets int32 `json:"max_planets"`
MaxPopulation int32 `json:"max_population"`
}
// Application mirrors a row in `backend.applications`.
type Application struct {
ApplicationID uuid.UUID
GameID uuid.UUID
ApplicantUserID uuid.UUID
RaceName string
Status string
CreatedAt time.Time
DecidedAt *time.Time
}
// Invite mirrors a row in `backend.invites`. `InvitedUserID` is nil for
// code-based invites; `Code` is non-empty for those.
type Invite struct {
InviteID uuid.UUID
GameID uuid.UUID
InviterUserID uuid.UUID
InvitedUserID *uuid.UUID
Code string
Status string
RaceName string
CreatedAt time.Time
ExpiresAt time.Time
DecidedAt *time.Time
}
// Membership mirrors a row in `backend.memberships`. `CanonicalKey` is
// the canonical form of `RaceName` produced by the Race Name Directory
// policy at write time.
type Membership struct {
MembershipID uuid.UUID
GameID uuid.UUID
UserID uuid.UUID
RaceName string
CanonicalKey string
Status string
JoinedAt time.Time
RemovedAt *time.Time
}
// RaceNameEntry mirrors a row in `backend.race_names`.
//
// Status `registered` rows store the all-zero sentinel UUID in `GameID`
// so the partial UNIQUE index `race_names_registered_uidx` covers the
// uniqueness rule. Status `reservation` and `pending_registration` rows
// store the originating `game_id`.
type RaceNameEntry struct {
Name string
Canonical CanonicalKey
Status string
OwnerUserID uuid.UUID
GameID uuid.UUID
SourceGameID *uuid.UUID
ReservedAt *time.Time
ExpiresAt *time.Time
RegisteredAt *time.Time
}
// IsRegistered reports whether the entry is platform-permanent.
func (e RaceNameEntry) IsRegistered() bool {
return e.Status == RaceNameStatusRegistered
}
// IsReservation reports whether the entry binds the canonical key to a
// concrete game without permanent ownership.
func (e RaceNameEntry) IsReservation() bool {
return e.Status == RaceNameStatusReservation
}
// IsPending reports whether the entry is awaiting capable-finish
// registration.
func (e RaceNameEntry) IsPending() bool {
return e.Status == RaceNameStatusPendingRegistration
}
// raceNameRegisteredGameSentinel is the sentinel UUID stored in
// `race_names.game_id` for `registered` rows. Mirrors the migration's
// `DEFAULT '00000000-0000-0000-0000-000000000000'` clause.
var raceNameRegisteredGameSentinel = uuid.UUID{}