6d6a384bee
Previously a cancelled / finished / start_failed sandbox game would hang in the dev user's lobby until manually cleaned up — `make up` would create a new running game alongside it but the dead tiles piled up. Now backend's `devsandbox.Bootstrap` deletes every terminal sandbox game owned by the dev user before find-or-create runs, so the lobby always shows exactly one running tile. Schema: `runtime_records` and `player_mappings` gain `ON DELETE CASCADE` on their `game_id` foreign keys so a single `DELETE FROM games` cleans every referencing row in one write. Pre-prod migration rule applies — change goes into `00001_init.sql`, not a new migration. API: `lobby.Service.DeleteGame` is the new destructive helper that backs the bootstrap purge. It bypasses the cancel-cascade-notify pipeline; production callers must stay on the regular lifecycle. The dev-sandbox docs in `tools/local-dev/README.md` spell out the new behaviour. Tests: - backend/internal/lobby/lobby_e2e_test.go gains `TestDeleteGameCascadesEverything` proving CASCADE works end-to-end against a real Postgres testcontainer. - backend/internal/devsandbox keeps its existing terminal-status contract test; the new `purgeTerminalSandboxGames` helper rides on the same `terminalSandboxStatus` predicate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
288 lines
9.8 KiB
Go
288 lines
9.8 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)
|
|
}
|
|
|
|
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
|
|
// the dev user owns that has reached a terminal state
|
|
// (cancelled / finished / start_failed). The cascade declared in
|
|
// `00001_init.sql` removes the matching memberships, applications,
|
|
// invites, runtime records, and player mappings in the same write,
|
|
// so the developer's lobby never piles up dead tiles between
|
|
// `make rebuild` cycles. Non-terminal games are left untouched —
|
|
// a `running` sandbox from a previous boot is the happy path.
|
|
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
|
|
games, err := svc.ListMyGames(ctx, ownerID)
|
|
if err != nil {
|
|
return 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
|
|
}
|
|
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
|
|
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
|
|
}
|
|
logger.Info("purged terminal sandbox game",
|
|
zap.String("game_id", g.GameID.String()),
|
|
zap.String("status", g.Status),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
// `purgeTerminalSandboxGames` ran before us, so any sandbox
|
|
// game still in the list is either a live one we should
|
|
// reuse or a transient state we can drive forward.
|
|
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
|
|
}
|