// Package devsandbox provisions a ready-to-play game on backend boot // for the `tools/local-dev` stack. // // Bootstrap is invoked from `backend/cmd/backend/main.go` after the // admin bootstrap and before the HTTP listener starts. It reads // `cfg.DevSandbox`; when `Email` is empty (the production posture) // the function logs "skipped" and returns nil. When set, it // idempotently: // // 1. registers the configured engine version and image; // 2. find-or-creates the real dev user with the configured email; // 3. find-or-creates `cfg.PlayerCount - 1` deterministic dummy // users so the engine's minimum-players constraint is met; // 4. find-or-creates a private "Dev Sandbox" game owned by the // real user with min/max_players = cfg.PlayerCount and a // year-out turn schedule (effectively frozen at turn 1); // 5. inserts memberships for all participants bypassing the // application/approval flow; // 6. drives the lifecycle to `running` (or as far as possible if // the runtime is busy). // // The function is a no-op on subsequent boots once the game is // running; partial states from earlier crashes are recovered. package devsandbox import ( "context" "errors" "fmt" "time" "galaxy/backend/internal/config" "galaxy/backend/internal/lobby" "galaxy/backend/internal/runtime" "github.com/google/uuid" "go.uber.org/zap" ) // SandboxGameName is the display name used to identify the // auto-provisioned game on subsequent reboots. The combination of // game_name and owner_user_id is unique enough in practice — only // the dev sandbox bootstrap creates a game owned by the configured // real user with this exact name. const SandboxGameName = "Dev Sandbox" // SandboxTurnSchedule keeps the game on turn 1 by scheduling the // next turn a year out. The runtime scheduler still parses this and // will tick once a year — long enough to never interfere with // solo UI development. const SandboxTurnSchedule = "0 0 1 1 *" // UserEnsurer matches `auth.UserEnsurer`. We define a local // interface to avoid importing the auth package and circular // dependencies — the production wiring passes the same `*user.Service` // instance used by auth. type UserEnsurer interface { EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error) } // Deps aggregates the collaborators Bootstrap needs. type Deps struct { Users UserEnsurer Lobby *lobby.Service EngineVersions *runtime.EngineVersionService } // Bootstrap runs the seven-step provisioning flow described on the // package doc comment. Errors are returned to the caller; the boot // path in `cmd/backend/main.go` aborts startup if Bootstrap fails so // a misconfigured dev environment surfaces immediately rather than // silently leaving the lobby empty. func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logger *zap.Logger) error { if logger == nil { logger = zap.NewNop() } logger = logger.Named("dev_sandbox") if cfg.Email == "" { logger.Info("skipped (no email)") return nil } if deps.Users == nil || deps.Lobby == nil || deps.EngineVersions == nil { return errors.New("dev_sandbox: deps.Users, deps.Lobby and deps.EngineVersions are required") } if cfg.PlayerCount <= 0 { return fmt.Errorf("dev_sandbox: PlayerCount must be positive, got %d", cfg.PlayerCount) } if err := ensureEngineVersion(ctx, deps.EngineVersions, cfg, logger); err != nil { return err } realID, err := deps.Users.EnsureByEmail(ctx, cfg.Email, "en", "UTC", "") if err != nil { return fmt.Errorf("dev_sandbox: ensure real user: %w", err) } dummyIDs := make([]uuid.UUID, 0, cfg.PlayerCount-1) for i := 1; i < cfg.PlayerCount; i++ { email := fmt.Sprintf("dev-dummy-%02d@local.test", i) id, err := deps.Users.EnsureByEmail(ctx, email, "en", "UTC", "") if err != nil { return fmt.Errorf("dev_sandbox: ensure dummy %d: %w", i, err) } dummyIDs = append(dummyIDs, id) } game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg) if err != nil { return err } game, err = ensureMembershipsAndDrive(ctx, deps.Lobby, game, realID, dummyIDs, logger) if err != nil { return err } logger.Info("bootstrap complete", zap.String("user_id", realID.String()), zap.String("game_id", game.GameID.String()), zap.String("status", game.Status), ) return nil } func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, cfg config.DevSandboxConfig, logger *zap.Logger) error { _, err := svc.Register(ctx, runtime.RegisterInput{ Version: cfg.EngineVersion, ImageRef: cfg.EngineImage, }) switch { case err == nil: logger.Info("engine version registered", zap.String("version", cfg.EngineVersion), zap.String("image", cfg.EngineImage), ) return nil case errors.Is(err, runtime.ErrEngineVersionTaken): logger.Debug("engine version already registered", zap.String("version", cfg.EngineVersion), ) return nil default: return fmt.Errorf("dev_sandbox: register engine version: %w", err) } } // terminalSandboxStatus reports whether a sandbox game has reached a // state from which it can no longer be driven back to running. We // treat such games as "absent" so the next bootstrap creates a fresh // one rather than handing the developer a dead lobby tile. func terminalSandboxStatus(status string) bool { switch status { case lobby.GameStatusCancelled, lobby.GameStatusFinished, lobby.GameStatusStartFailed: return true } return false } func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) { games, err := svc.ListMyGames(ctx, ownerID) if err != nil { return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err) } for _, g := range games { if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID { continue } if terminalSandboxStatus(g.Status) { continue } return g, nil } rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{ OwnerUserID: &ownerID, Visibility: lobby.VisibilityPrivate, GameName: SandboxGameName, Description: "Auto-provisioned by backend/internal/devsandbox for solo UI development.", MinPlayers: int32(cfg.PlayerCount), MaxPlayers: int32(cfg.PlayerCount), StartGapHours: 0, StartGapPlayers: 0, EnrollmentEndsAt: time.Now().Add(365 * 24 * time.Hour), TurnSchedule: SandboxTurnSchedule, TargetEngineVersion: cfg.EngineVersion, }) if err != nil { return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: create game: %w", err) } return rec, nil } func ensureMembershipsAndDrive(ctx context.Context, svc *lobby.Service, game lobby.GameRecord, realID uuid.UUID, dummyIDs []uuid.UUID, logger *zap.Logger) (lobby.GameRecord, error) { caller := realID if game.Status == lobby.GameStatusDraft { next, err := svc.OpenEnrollment(ctx, &caller, false, game.GameID) if err != nil { return game, fmt.Errorf("dev_sandbox: open enrollment: %w", err) } game = next } if game.Status == lobby.GameStatusEnrollmentOpen { users := append([]uuid.UUID{realID}, dummyIDs...) for i, uid := range users { raceName := fmt.Sprintf("Sandbox-%02d", i+1) if _, err := svc.InsertMembershipDirect(ctx, lobby.InsertMembershipDirectInput{ GameID: game.GameID, UserID: uid, RaceName: raceName, }); err != nil { return game, fmt.Errorf("dev_sandbox: insert membership %d: %w", i+1, err) } } logger.Info("memberships ensured", zap.Int("count", len(users)), zap.String("game_id", game.GameID.String()), ) next, err := svc.ReadyToStart(ctx, &caller, false, game.GameID) if err != nil { return game, fmt.Errorf("dev_sandbox: ready to start: %w", err) } game = next } if game.Status == lobby.GameStatusReadyToStart { next, err := svc.Start(ctx, &caller, false, game.GameID) if err != nil { return game, fmt.Errorf("dev_sandbox: start: %w", err) } game = next } if game.Status == lobby.GameStatusStartFailed { next, err := svc.RetryStart(ctx, &caller, false, game.GameID) if err != nil { logger.Warn("retry start failed", zap.Error(err)) return game, nil } game = next if game.Status == lobby.GameStatusReadyToStart { next, err := svc.Start(ctx, &caller, false, game.GameID) if err != nil { return game, fmt.Errorf("dev_sandbox: start after retry: %w", err) } game = next } } return game, nil }