From e63748c344009ee454d2c1ca306b2142d2a5361f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 8 May 2026 15:51:09 +0200 Subject: [PATCH] local-dev: boot-time dev sandbox provisions a runnable game on up 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 --- backend/cmd/backend/main.go | 9 + backend/internal/config/config.go | 51 ++++ backend/internal/devsandbox/bootstrap.go | 232 ++++++++++++++++++ backend/internal/devsandbox/bootstrap_test.go | 86 +++++++ backend/internal/lobby/membership_direct.go | 96 ++++++++ tools/local-dev/.env | 10 + tools/local-dev/Makefile | 20 +- tools/local-dev/README.md | 44 +++- tools/local-dev/docker-compose.yml | 19 +- 9 files changed, 559 insertions(+), 8 deletions(-) create mode 100644 backend/internal/devsandbox/bootstrap.go create mode 100644 backend/internal/devsandbox/bootstrap_test.go create mode 100644 backend/internal/lobby/membership_direct.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 0efd1d4..d6ecdc0 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -24,6 +24,7 @@ import ( "galaxy/backend/internal/app" "galaxy/backend/internal/auth" "galaxy/backend/internal/config" + "galaxy/backend/internal/devsandbox" "galaxy/backend/internal/dockerclient" "galaxy/backend/internal/engineclient" "galaxy/backend/internal/geo" @@ -265,6 +266,14 @@ func run(ctx context.Context) (err error) { ) runtimeGateway.svc = runtimeSvc + if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{ + Users: userSvc, + Lobby: lobbySvc, + EngineVersions: engineVersionSvc, + }, cfg.DevSandbox, logger); err != nil { + return fmt.Errorf("dev sandbox bootstrap: %w", err) + } + notifStore := notification.NewStore(db) notifSvc := notification.NewService(notification.Deps{ Store: notifStore, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 08170a6..536e206 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -95,6 +95,11 @@ const ( envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL" envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL" envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS" + + envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL" + envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE" + envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION" + envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT" ) // Default values applied when an environment variable is absent. @@ -157,6 +162,9 @@ const ( defaultNotificationWorkerInterval = 5 * time.Second defaultNotificationMaxAttempts = 8 + + defaultDevSandboxEngineVersion = "0.1.0" + defaultDevSandboxPlayerCount = 20 ) // Allowed values for the closed-set string options. @@ -193,12 +201,29 @@ type Config struct { Engine EngineConfig Runtime RuntimeConfig Notification NotificationConfig + DevSandbox DevSandboxConfig // FreshnessWindow mirrors the gateway freshness window and is used by the // push server to bound the cursor TTL. FreshnessWindow time.Duration } +// DevSandboxConfig configures the boot-time bootstrap implemented in +// `backend/internal/devsandbox`. When Email is empty the bootstrap +// is a no-op, which is the production posture. When Email is set — +// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack — +// the bootstrap idempotently provisions a real user, the configured +// number of dummy participants, a private "Dev Sandbox" game, the +// matching memberships, and drives the lifecycle to `running`. The +// engine image and engine version refer to a row that the bootstrap +// also seeds in `engine_versions`. +type DevSandboxConfig struct { + Email string + EngineImage string + EngineVersion string + PlayerCount int +} + // LoggingConfig stores the parameters used by the structured logger. type LoggingConfig struct { // Level is the zap level name (e.g. "debug", "info", "warn", "error"). @@ -469,6 +494,10 @@ func DefaultConfig() Config { WorkerInterval: defaultNotificationWorkerInterval, MaxAttempts: defaultNotificationMaxAttempts, }, + DevSandbox: DevSandboxConfig{ + EngineVersion: defaultDevSandboxEngineVersion, + PlayerCount: defaultDevSandboxPlayerCount, + }, Runtime: RuntimeConfig{ WorkerPoolSize: defaultRuntimeWorkerPoolSize, JobQueueSize: defaultRuntimeJobQueueSize, @@ -628,6 +657,13 @@ func LoadFromEnv() (Config, error) { return Config{}, err } + cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email)) + cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage)) + cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion)) + if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil { + return Config{}, err + } + if err := cfg.Validate(); err != nil { return Config{}, err } @@ -823,6 +859,21 @@ func (c Config) Validate() error { } } + if email := strings.TrimSpace(c.DevSandbox.Email); email != "" { + if _, err := netmail.ParseAddress(email); err != nil { + return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err) + } + if strings.TrimSpace(c.DevSandbox.EngineImage) == "" { + return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail) + } + if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" { + return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail) + } + if c.DevSandbox.PlayerCount <= 0 { + return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail) + } + } + return nil } diff --git a/backend/internal/devsandbox/bootstrap.go b/backend/internal/devsandbox/bootstrap.go new file mode 100644 index 0000000..63eb6a2 --- /dev/null +++ b/backend/internal/devsandbox/bootstrap.go @@ -0,0 +1,232 @@ +// 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 +} diff --git a/backend/internal/devsandbox/bootstrap_test.go b/backend/internal/devsandbox/bootstrap_test.go new file mode 100644 index 0000000..31e7cc6 --- /dev/null +++ b/backend/internal/devsandbox/bootstrap_test.go @@ -0,0 +1,86 @@ +package devsandbox + +import ( + "context" + "errors" + "testing" + + "galaxy/backend/internal/config" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// TestBootstrapSkippedWhenEmailEmpty exercises the no-op branch: with +// the production posture (Email == "") Bootstrap must return without +// touching any dependency. The fact that Users/Lobby/EngineVersions +// are nil here doubles as a check that the early-return runs first. +func TestBootstrapSkippedWhenEmailEmpty(t *testing.T) { + err := Bootstrap( + context.Background(), + Deps{}, + config.DevSandboxConfig{}, + zap.NewNop(), + ) + if err != nil { + t.Fatalf("expected nil error on empty email, got: %v", err) + } +} + +// TestBootstrapRejectsZeroPlayerCount confirms the validation +// short-circuits the flow before any DB call when PlayerCount is +// non-positive but Email is set. The error path is fast and never +// dereferences the (still-nil) Users/Lobby deps. +func TestBootstrapRejectsZeroPlayerCount(t *testing.T) { + err := Bootstrap( + context.Background(), + Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil}, + config.DevSandboxConfig{ + Email: "dev@local.test", + EngineImage: "galaxy-engine:local-dev", + EngineVersion: "0.0.0-local-dev", + PlayerCount: 0, + }, + zap.NewNop(), + ) + if err == nil { + t.Fatal("expected error on zero PlayerCount, got nil") + } +} + +// TestBootstrapRejectsMissingDeps checks that a misconfigured wiring +// (Email set but one of the required services nil) fails fast rather +// than panicking when the bootstrap reaches its first service call. +func TestBootstrapRejectsMissingDeps(t *testing.T) { + err := Bootstrap( + context.Background(), + Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil}, + config.DevSandboxConfig{ + Email: "dev@local.test", + EngineImage: "galaxy-engine:local-dev", + EngineVersion: "0.0.0-local-dev", + PlayerCount: 20, + }, + zap.NewNop(), + ) + if err == nil { + t.Fatal("expected error on missing deps, got nil") + } + if !errors.Is(err, errMissingDepsSentinel) && err.Error() == "" { + // The exact wording is not part of the contract; this branch + // only asserts the error is non-nil and human-readable. + t.Fatalf("error has empty message: %v", err) + } +} + +// errMissingDepsSentinel exists so the assertion above can compile; +// the real error is constructed via errors.New inside Bootstrap and +// is intentionally not exported. The test only needs to confirm the +// returned error has a message. +var errMissingDepsSentinel = errors.New("sentinel") + +type stubEnsurer struct{} + +func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) { + return uuid.UUID{}, nil +} diff --git a/backend/internal/lobby/membership_direct.go b/backend/internal/lobby/membership_direct.go new file mode 100644 index 0000000..1a9201c --- /dev/null +++ b/backend/internal/lobby/membership_direct.go @@ -0,0 +1,96 @@ +package lobby + +import ( + "context" + "fmt" + + "github.com/google/uuid" +) + +// InsertMembershipDirectInput is the parameter struct for +// Service.InsertMembershipDirect. +type InsertMembershipDirectInput struct { + GameID uuid.UUID + UserID uuid.UUID + RaceName string +} + +// InsertMembershipDirect grants a membership to userID inside gameID +// bypassing the application/approval flow. It performs the same DB +// writes as ApproveApplication: the per-game race-name reservation +// row plus the membership row, and refreshes the in-memory caches. +// +// The method is intended for boot-time provisioning by +// `backend/internal/devsandbox` and similar trusted callers. It is +// not exposed through any HTTP handler. The caller must guarantee +// game.Status == GameStatusEnrollmentOpen — the function returns +// ErrConflict otherwise — and that the race-name policy and +// canonical-key invariants are honoured (the implementation reuses +// the lobby's own Policy and assertRaceNameAvailable so a duplicate +// or unsuitable name still fails). +// +// Idempotency: if a membership for (GameID, UserID) already exists +// the function returns the existing row without modifying state. +// This makes the helper safe to call on every backend boot from +// devsandbox.Bootstrap. +func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) { + displayName, err := ValidateDisplayName(in.RaceName) + if err != nil { + return Membership{}, err + } + game, err := s.GetGame(ctx, in.GameID) + if err != nil { + return Membership{}, err + } + if game.Status != GameStatusEnrollmentOpen { + return Membership{}, fmt.Errorf("%w: game status is %q, want enrollment_open", ErrConflict, game.Status) + } + canonical, err := s.deps.Policy.Canonical(displayName) + if err != nil { + return Membership{}, err + } + existing, err := s.deps.Store.ListMembershipsForGame(ctx, in.GameID) + if err != nil { + return Membership{}, err + } + for _, m := range existing { + if m.UserID == in.UserID && m.Status == MembershipStatusActive { + return m, nil + } + } + if err := s.assertRaceNameAvailable(ctx, canonical, in.UserID, in.GameID); err != nil { + return Membership{}, err + } + now := s.deps.Now().UTC() + if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{ + Name: displayName, + Canonical: canonical, + Status: RaceNameStatusReservation, + OwnerUserID: in.UserID, + GameID: in.GameID, + ReservedAt: &now, + }); err != nil { + return Membership{}, err + } + membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{ + MembershipID: uuid.New(), + GameID: in.GameID, + UserID: in.UserID, + RaceName: displayName, + CanonicalKey: canonical, + }) + if err != nil { + _ = s.deps.Store.DeleteRaceName(ctx, canonical, in.GameID) + return Membership{}, err + } + s.deps.Cache.PutMembership(membership) + s.deps.Cache.PutRaceName(RaceNameEntry{ + Name: displayName, + Canonical: canonical, + Status: RaceNameStatusReservation, + OwnerUserID: in.UserID, + GameID: in.GameID, + ReservedAt: &now, + }) + return membership, nil +} diff --git a/tools/local-dev/.env b/tools/local-dev/.env index e2dfecf..e7470a6 100644 --- a/tools/local-dev/.env +++ b/tools/local-dev/.env @@ -6,3 +6,13 @@ # real bcrypt-verified code. Leave the value blank to disable the # override and force every login through Mailpit. BACKEND_AUTH_DEV_FIXED_CODE=123456 + +# Boot-time dev sandbox (backend/internal/devsandbox). When EMAIL is +# non-empty the backend ensures a real user with that address, the +# configured number of dummy participants, a private "Dev Sandbox" +# game, and drives the lifecycle to running on every boot. Leave +# EMAIL blank to disable the bootstrap entirely. +BACKEND_DEV_SANDBOX_EMAIL=dev@local.test +BACKEND_DEV_SANDBOX_ENGINE_IMAGE=galaxy-engine:local-dev +BACKEND_DEV_SANDBOX_ENGINE_VERSION=0.1.0 +BACKEND_DEV_SANDBOX_PLAYER_COUNT=20 diff --git a/tools/local-dev/Makefile b/tools/local-dev/Makefile index 761fe50..22dc4fc 100644 --- a/tools/local-dev/Makefile +++ b/tools/local-dev/Makefile @@ -1,14 +1,17 @@ -.PHONY: help up down logs status rebuild clean psql logs-backend logs-gateway logs-mail wait +.PHONY: help up down logs status rebuild clean psql logs-backend logs-gateway logs-mail build-engine wait .DEFAULT_GOAL := help COMPOSE := docker compose +REPO_ROOT := $(realpath $(CURDIR)/../..) +ENGINE_IMAGE := galaxy-engine:local-dev help: @echo "Local development stack for the Galaxy UI:" @echo " make up Build (if needed) and bring up the stack, wait until healthy" @echo " make down Stop containers, keep volumes" @echo " make rebuild Force rebuild of backend / gateway images and bring up" + @echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by the dev sandbox" @echo " make clean Stop and wipe volumes (postgres data, game state)" @echo " make logs Tail all logs" @echo " make logs-backend Tail only the backend logs" @@ -20,14 +23,25 @@ help: @echo "After 'make up', point the UI at the stack with:" @echo " pnpm -C ui/frontend dev" @echo "and open http://localhost:5173 (UI) plus http://localhost:8025 (Mailpit)." + @echo "" + @echo "Default login for the auto-provisioned dev sandbox: dev@local.test" + @echo "(see BACKEND_DEV_SANDBOX_EMAIL in .env). Login code: 123456." -up: +up: build-engine $(COMPOSE) up -d --wait -rebuild: +rebuild: build-engine $(COMPOSE) build --no-cache backend gateway $(COMPOSE) up -d --wait +build-engine: + @if docker image inspect $(ENGINE_IMAGE) >/dev/null 2>&1; then \ + echo "$(ENGINE_IMAGE) already built; skipping (use 'docker rmi $(ENGINE_IMAGE)' to force a rebuild)."; \ + else \ + echo "building $(ENGINE_IMAGE)…"; \ + docker build -t $(ENGINE_IMAGE) -f $(REPO_ROOT)/game/Dockerfile $(REPO_ROOT); \ + fi + down: $(COMPOSE) down diff --git a/tools/local-dev/README.md b/tools/local-dev/README.md index 4cad8fa..a570d97 100644 --- a/tools/local-dev/README.md +++ b/tools/local-dev/README.md @@ -35,6 +35,11 @@ pnpm -C ui/frontend dev Open for the UI and for Mailpit. +The first `make up` builds the engine image (`galaxy-engine:local-dev`) +from `game/Dockerfile`. Subsequent invocations skip the build when the +image already exists; force a rebuild with `docker rmi galaxy-engine:local-dev` +followed by `make build-engine`. + ## Daily flow ```sh @@ -69,6 +74,42 @@ To force the second path (no fast-bypass), edit `make rebuild` (or simply `docker compose up -d backend` to recreate the backend with the new env). +## Auto-provisioned dev sandbox + +`make up` provisions a private game called **Dev Sandbox** owned by +the dev user (default `dev@local.test`). The flow is implemented in +`backend/internal/devsandbox` and runs on every backend boot when +`BACKEND_DEV_SANDBOX_EMAIL` is non-empty in `tools/local-dev/.env`. + +Bootstrap is idempotent — re-running `make up` after a `make down` +finds the existing user, dummy participants, game, and memberships +without creating duplicates. If a previous boot crashed mid-way +(game stuck in `enrollment_open` or `ready_to_start`), the next boot +resumes the lifecycle. + +To log in straight into the sandbox: + +1. `make -C tools/local-dev up` +2. `pnpm -C ui/frontend dev` (in another terminal) +3. Open , enter `dev@local.test`, then + the dev code `123456`. +4. The lobby shows **Dev Sandbox** in *My Games*; click in. + +To disable the bootstrap, clear `BACKEND_DEV_SANDBOX_EMAIL` in +`tools/local-dev/.env` and `docker compose up -d backend` (or +`make rebuild`). Existing users / games are not removed. + +The bootstrap requires: +- `galaxy-engine:local-dev` Docker image (`make build-engine`). +- `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver + (`MAJOR.MINOR.PATCH`); the default `0.1.0` is what the bootstrap + registers in the `engine_versions` row that points at the image. +- `BACKEND_DEV_SANDBOX_PLAYER_COUNT` ≥ 20 (the engine's minimum; + 19 deterministic dummies fill the slots so the single real user + can start the game). +- A frozen turn schedule (`0 0 1 1 *` — once a year) so the visible + game state stays at turn 1 until you explicitly progress it. + ## Network map ``` @@ -105,8 +146,9 @@ To point the proxy at a non-local gateway, run ## Make targets ```text -make up Bring up the stack (build if needed) and wait for health +make up Bring up the stack (build engine + compose images if needed) and wait for health make rebuild Rebuild the backend / gateway images (ignores cache) +make build-engine Build galaxy-engine:local-dev from game/Dockerfile (no-op if image already present) make down Stop containers, keep volumes make clean Stop and wipe volumes (postgres + game-state) make logs Tail every service's logs diff --git a/tools/local-dev/docker-compose.yml b/tools/local-dev/docker-compose.yml index 28e6b35..c450d2b 100644 --- a/tools/local-dev/docker-compose.yml +++ b/tools/local-dev/docker-compose.yml @@ -102,7 +102,7 @@ services: BACKEND_SMTP_FROM: "galaxy-backend@galaxy.local" BACKEND_SMTP_TLS_MODE: none BACKEND_DOCKER_NETWORK: galaxy-local-dev-net - BACKEND_GAME_STATE_ROOT: /var/lib/galaxy/game-state + BACKEND_GAME_STATE_ROOT: /tmp/galaxy-game-state BACKEND_GEOIP_DB_PATH: /var/lib/galaxy/geoip.mmdb BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.local BACKEND_AUTH_CHALLENGE_THROTTLE_MAX: "100" @@ -111,9 +111,22 @@ services: BACKEND_OTEL_TRACES_EXPORTER: none BACKEND_OTEL_METRICS_EXPORTER: none BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-} + BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-} + BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-} + BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-} + BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-} volumes: - /var/run/docker.sock:/var/run/docker.sock - - game-state:/var/lib/galaxy/game-state + # Per-game state directories live under the same absolute path + # both inside the backend container and on the Docker daemon + # host (colima VM), so the bind-mount source the backend hands + # to the daemon resolves correctly when spawning engine + # containers. See backend/internal/runtime/service.go:454. + - type: bind + source: /tmp/galaxy-game-state + target: /tmp/galaxy-game-state + bind: + create_host_path: true - ../../pkg/geoip/test-data/test-data/GeoIP2-Country-Test.mmdb:/var/lib/galaxy/geoip.mmdb:ro networks: - galaxy-net @@ -182,5 +195,3 @@ networks: volumes: postgres-data: name: galaxy-local-dev-postgres-data - game-state: - name: galaxy-local-dev-game-state