e63748c344
Adds backend/internal/devsandbox: an idempotent boot-time hook that, when BACKEND_DEV_SANDBOX_EMAIL is set, ensures (1) the configured engine_version row, (2) the real dev user, (3) PlayerCount-1 deterministic dummy users, (4) a private "Dev Sandbox" game with a year-out turn schedule, (5) memberships for every participant via the new lobby.Service.InsertMembershipDirect helper, (6) a drive of the lifecycle to running. Re-running on a populated DB is a no-op; partial states from earlier crashes are recovered. tools/local-dev gains the matching env vars in .env, surfaces them in compose, and acquires a `make build-engine` target that builds galaxy-engine:local-dev from game/Dockerfile (a prerequisite of `up`/`rebuild`). The compose game-state mount is changed from a named volume to a host bind on /tmp/galaxy-game-state so backend's bind-mount source for spawned engine containers resolves on the docker daemon. After `make -C tools/local-dev up`, login as dev@local.test with the dev code 123456 and the Dev Sandbox already shows up in My Games. Per-user behaviour for the same email survives a backend restart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
233 lines
7.7 KiB
Go
233 lines
7.7 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)
|
|
}
|
|
}
|
|
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
|
|
}
|