feat: backend service
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
@@ -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{}
|
||||
Reference in New Issue
Block a user