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) } // DeleteGame removes the game and every referencing row (memberships, // applications, invites, runtime_records, player_mappings) via the // `ON DELETE CASCADE` constraints declared in `00001_init.sql`. // Idempotent: returns nil when no game matches. // // Phase 14 introduces this method for the dev-sandbox bootstrap so a // terminal "Dev Sandbox" tile from a previous local-dev session can // be scrubbed before a fresh game spawns. Production callers must // stay on the regular cancel / finish lifecycle — `DeleteGame` is // destructive and bypasses the cascade-notification machinery. func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error { if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil { return err } s.deps.Cache.RemoveGame(gameID) return nil } // 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 }