0f8f8698bd
Three fixes around the dev sandbox end-to-end path. Each one was flushed out by an actual login walkthrough after the previous commit. Backend bootstrap now treats `cancelled`, `finished`, and `start_failed` as terminal: the per-boot find-or-create skips such games and provisions a fresh one. Without this, a single bad shutdown cascade leaves the developer staring at a dead lobby tile forever (cancelled games don't transition back). Covered by TestTerminalSandboxStatus. Tools/local-dev: stop killing engine containers in `make down`. The runtime treats the disappearance of an engine as a real failure (cascading the lobby game to `cancelled`); leaving the container running across `down/up` lets the runtime reconciler re-attach on the next boot. The teardown happens only in `make clean`, where the DB is wiped anyway. Compose now also exposes :9090 (authenticated EdgeGateway listener) on the host so the Vite dev proxy can reach the Connect-Web surface, and bumps the gateway anti-abuse limits for `public_misc` so the same surface is not blanket-rejected with 413. Ui/frontend: the lobby's `My Games` cards are now clickable only for the playable statuses (`running`, `paused`, `finished`). All other statuses render as disabled buttons so a click on a draft or cancelled game no longer drops the user on a 404 — the in-game view at /games/:id/* doesn't exist before Phase 10 and never makes sense for a cancelled game. Vite proxy splits the dev targets so `/api/*` continues to talk to the REST listener and `/galaxy.gateway.v1.EdgeGateway/*` is routed to the Connect-Web listener via VITE_DEV_GRPC_PROXY_TARGET (defaults to :9090). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
253 lines
8.4 KiB
Go
253 lines
8.4 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|