ui: plan 01-27 done #1
@@ -24,6 +24,7 @@ import (
|
|||||||
"galaxy/backend/internal/app"
|
"galaxy/backend/internal/app"
|
||||||
"galaxy/backend/internal/auth"
|
"galaxy/backend/internal/auth"
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
|
"galaxy/backend/internal/devsandbox"
|
||||||
"galaxy/backend/internal/dockerclient"
|
"galaxy/backend/internal/dockerclient"
|
||||||
"galaxy/backend/internal/engineclient"
|
"galaxy/backend/internal/engineclient"
|
||||||
"galaxy/backend/internal/geo"
|
"galaxy/backend/internal/geo"
|
||||||
@@ -265,6 +266,14 @@ func run(ctx context.Context) (err error) {
|
|||||||
)
|
)
|
||||||
runtimeGateway.svc = runtimeSvc
|
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)
|
notifStore := notification.NewStore(db)
|
||||||
notifSvc := notification.NewService(notification.Deps{
|
notifSvc := notification.NewService(notification.Deps{
|
||||||
Store: notifStore,
|
Store: notifStore,
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ const (
|
|||||||
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
||||||
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
||||||
envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"
|
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.
|
// Default values applied when an environment variable is absent.
|
||||||
@@ -157,6 +162,9 @@ const (
|
|||||||
|
|
||||||
defaultNotificationWorkerInterval = 5 * time.Second
|
defaultNotificationWorkerInterval = 5 * time.Second
|
||||||
defaultNotificationMaxAttempts = 8
|
defaultNotificationMaxAttempts = 8
|
||||||
|
|
||||||
|
defaultDevSandboxEngineVersion = "0.1.0"
|
||||||
|
defaultDevSandboxPlayerCount = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// Allowed values for the closed-set string options.
|
// Allowed values for the closed-set string options.
|
||||||
@@ -193,12 +201,29 @@ type Config struct {
|
|||||||
Engine EngineConfig
|
Engine EngineConfig
|
||||||
Runtime RuntimeConfig
|
Runtime RuntimeConfig
|
||||||
Notification NotificationConfig
|
Notification NotificationConfig
|
||||||
|
DevSandbox DevSandboxConfig
|
||||||
|
|
||||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||||
// push server to bound the cursor TTL.
|
// push server to bound the cursor TTL.
|
||||||
FreshnessWindow time.Duration
|
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.
|
// LoggingConfig stores the parameters used by the structured logger.
|
||||||
type LoggingConfig struct {
|
type LoggingConfig struct {
|
||||||
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
||||||
@@ -469,6 +494,10 @@ func DefaultConfig() Config {
|
|||||||
WorkerInterval: defaultNotificationWorkerInterval,
|
WorkerInterval: defaultNotificationWorkerInterval,
|
||||||
MaxAttempts: defaultNotificationMaxAttempts,
|
MaxAttempts: defaultNotificationMaxAttempts,
|
||||||
},
|
},
|
||||||
|
DevSandbox: DevSandboxConfig{
|
||||||
|
EngineVersion: defaultDevSandboxEngineVersion,
|
||||||
|
PlayerCount: defaultDevSandboxPlayerCount,
|
||||||
|
},
|
||||||
Runtime: RuntimeConfig{
|
Runtime: RuntimeConfig{
|
||||||
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
||||||
JobQueueSize: defaultRuntimeJobQueueSize,
|
JobQueueSize: defaultRuntimeJobQueueSize,
|
||||||
@@ -628,6 +657,13 @@ func LoadFromEnv() (Config, error) {
|
|||||||
return Config{}, err
|
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 {
|
if err := cfg.Validate(); err != nil {
|
||||||
return Config{}, err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,3 +6,13 @@
|
|||||||
# real bcrypt-verified code. Leave the value blank to disable the
|
# real bcrypt-verified code. Leave the value blank to disable the
|
||||||
# override and force every login through Mailpit.
|
# override and force every login through Mailpit.
|
||||||
BACKEND_AUTH_DEV_FIXED_CODE=123456
|
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
|
||||||
|
|||||||
@@ -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
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
COMPOSE := docker compose
|
COMPOSE := docker compose
|
||||||
|
REPO_ROOT := $(realpath $(CURDIR)/../..)
|
||||||
|
ENGINE_IMAGE := galaxy-engine:local-dev
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Local development stack for the Galaxy UI:"
|
@echo "Local development stack for the Galaxy UI:"
|
||||||
@echo " make up Build (if needed) and bring up the stack, wait until healthy"
|
@echo " make up Build (if needed) and bring up the stack, wait until healthy"
|
||||||
@echo " make down Stop containers, keep volumes"
|
@echo " make down Stop containers, keep volumes"
|
||||||
@echo " make rebuild Force rebuild of backend / gateway images and bring up"
|
@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 clean Stop and wipe volumes (postgres data, game state)"
|
||||||
@echo " make logs Tail all logs"
|
@echo " make logs Tail all logs"
|
||||||
@echo " make logs-backend Tail only the backend 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 "After 'make up', point the UI at the stack with:"
|
||||||
@echo " pnpm -C ui/frontend dev"
|
@echo " pnpm -C ui/frontend dev"
|
||||||
@echo "and open http://localhost:5173 (UI) plus http://localhost:8025 (Mailpit)."
|
@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
|
$(COMPOSE) up -d --wait
|
||||||
|
|
||||||
rebuild:
|
rebuild: build-engine
|
||||||
$(COMPOSE) build --no-cache backend gateway
|
$(COMPOSE) build --no-cache backend gateway
|
||||||
$(COMPOSE) up -d --wait
|
$(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:
|
down:
|
||||||
$(COMPOSE) down
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ pnpm -C ui/frontend dev
|
|||||||
Open <http://localhost:5173> for the UI and
|
Open <http://localhost:5173> for the UI and
|
||||||
<http://localhost:8025> for Mailpit.
|
<http://localhost:8025> 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
|
## Daily flow
|
||||||
|
|
||||||
```sh
|
```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
|
`make rebuild` (or simply `docker compose up -d backend` to recreate
|
||||||
the backend with the new env).
|
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 <http://localhost:5173/login>, 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
|
## Network map
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -105,8 +146,9 @@ To point the proxy at a non-local gateway, run
|
|||||||
## Make targets
|
## Make targets
|
||||||
|
|
||||||
```text
|
```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 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 down Stop containers, keep volumes
|
||||||
make clean Stop and wipe volumes (postgres + game-state)
|
make clean Stop and wipe volumes (postgres + game-state)
|
||||||
make logs Tail every service's logs
|
make logs Tail every service's logs
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ services:
|
|||||||
BACKEND_SMTP_FROM: "galaxy-backend@galaxy.local"
|
BACKEND_SMTP_FROM: "galaxy-backend@galaxy.local"
|
||||||
BACKEND_SMTP_TLS_MODE: none
|
BACKEND_SMTP_TLS_MODE: none
|
||||||
BACKEND_DOCKER_NETWORK: galaxy-local-dev-net
|
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_GEOIP_DB_PATH: /var/lib/galaxy/geoip.mmdb
|
||||||
BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.local
|
BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.local
|
||||||
BACKEND_AUTH_CHALLENGE_THROTTLE_MAX: "100"
|
BACKEND_AUTH_CHALLENGE_THROTTLE_MAX: "100"
|
||||||
@@ -111,9 +111,22 @@ services:
|
|||||||
BACKEND_OTEL_TRACES_EXPORTER: none
|
BACKEND_OTEL_TRACES_EXPORTER: none
|
||||||
BACKEND_OTEL_METRICS_EXPORTER: none
|
BACKEND_OTEL_METRICS_EXPORTER: none
|
||||||
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-}
|
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:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /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
|
- ../../pkg/geoip/test-data/test-data/GeoIP2-Country-Test.mmdb:/var/lib/galaxy/geoip.mmdb:ro
|
||||||
networks:
|
networks:
|
||||||
- galaxy-net
|
- galaxy-net
|
||||||
@@ -182,5 +195,3 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
name: galaxy-local-dev-postgres-data
|
name: galaxy-local-dev-postgres-data
|
||||||
game-state:
|
|
||||||
name: galaxy-local-dev-game-state
|
|
||||||
|
|||||||
Reference in New Issue
Block a user