804fdd2a72
Adds a single zap.Info line after the membership-insertion loop so the boot log explicitly shows how many participants the sandbox provisioned. The number is fixed by config (PlayerCount) but surfacing it in the log makes troubleshooting "why is the lobby empty" cases (typo in the email, partial failure) faster than querying the DB. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
237 lines
7.9 KiB
Go
237 lines
7.9 KiB
Go
// 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)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|