diff --git a/backend/internal/devsandbox/bootstrap.go b/backend/internal/devsandbox/bootstrap.go index 0e36dcf..849a94c 100644 --- a/backend/internal/devsandbox/bootstrap.go +++ b/backend/internal/devsandbox/bootstrap.go @@ -106,6 +106,10 @@ func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logg 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 @@ -158,6 +162,37 @@ func terminalSandboxStatus(status string) bool { 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 { @@ -167,9 +202,9 @@ func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uu if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID { continue } - if terminalSandboxStatus(g.Status) { - 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{ diff --git a/backend/internal/devsandbox/bootstrap_test.go b/backend/internal/devsandbox/bootstrap_test.go index d812283..714d6cd 100644 --- a/backend/internal/devsandbox/bootstrap_test.go +++ b/backend/internal/devsandbox/bootstrap_test.go @@ -80,9 +80,9 @@ func TestBootstrapRejectsMissingDeps(t *testing.T) { var errMissingDepsSentinel = errors.New("sentinel") // TestTerminalSandboxStatus pins the contract that decides whether a -// previously created sandbox game is reusable. Terminal states force -// the bootstrap to create a new game on the next boot rather than -// hand the developer a dead lobby tile. +// previously created sandbox game gets purged on the next boot. +// Terminal states are deleted (cascade-style) so the developer's +// lobby never piles up dead tiles between `make rebuild` cycles. func TestTerminalSandboxStatus(t *testing.T) { terminal := []string{"cancelled", "finished", "start_failed"} live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"} diff --git a/backend/internal/lobby/games.go b/backend/internal/lobby/games.go index 4abdcdb..2ac5241 100644 --- a/backend/internal/lobby/games.go +++ b/backend/internal/lobby/games.go @@ -233,6 +233,24 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco return s.deps.Store.ListMyGames(ctx, userID) } +// DeleteGame removes the game and every referencing row (memberships, +// applications, invites, runtime_records, player_mappings) via the +// `ON DELETE CASCADE` constraints declared in `00001_init.sql`. +// Idempotent: returns nil when no game matches. +// +// Phase 14 introduces this method for the dev-sandbox bootstrap so a +// terminal "Dev Sandbox" tile from a previous local-dev session can +// be scrubbed before a fresh game spawns. Production callers must +// stay on the regular cancel / finish lifecycle — `DeleteGame` is +// destructive and bypasses the cascade-notification machinery. +func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error { + if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil { + return err + } + s.deps.Cache.RemoveGame(gameID) + return nil +} + // State-machine transition handlers below take the same shape: load the // game (cache or store), check owner, validate the current status, run // the transition write, refresh the cache, optionally tell the runtime diff --git a/backend/internal/lobby/lobby_e2e_test.go b/backend/internal/lobby/lobby_e2e_test.go index 3f86a59..5e70f51 100644 --- a/backend/internal/lobby/lobby_e2e_test.go +++ b/backend/internal/lobby/lobby_e2e_test.go @@ -244,6 +244,70 @@ func TestEndToEndPrivateGameFlow(t *testing.T) { } } +// TestDeleteGameCascadesEverything pins the contract the dev-sandbox +// bootstrap relies on: removing a game wipes every referencing row +// (memberships, applications, invites, runtime_records, +// player_mappings) in a single SQL statement. Before this is wired +// the developer's lobby pile up cancelled tiles between +// `make rebuild` cycles; with it, every boot starts from a clean +// slate. +func TestDeleteGameCascadesEverything(t *testing.T) { + db := startPostgres(t) + now := time.Now().UTC() + clock := func() time.Time { return now } + svc := newServiceForTest(t, db, clock, 5) + + owner := uuid.New() + seedAccount(t, db, owner) + game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{ + OwnerUserID: &owner, + Visibility: lobby.VisibilityPrivate, + GameName: "Doomed", + MinPlayers: 1, + MaxPlayers: 4, + StartGapHours: 1, + StartGapPlayers: 1, + EnrollmentEndsAt: now.Add(time.Hour), + TurnSchedule: "0 0 * * *", + TargetEngineVersion: "1.0.0", + }) + if err != nil { + t.Fatalf("create game: %v", err) + } + if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil { + t.Fatalf("open enrollment: %v", err) + } + if _, err := svc.InsertMembershipDirect(context.Background(), lobby.InsertMembershipDirectInput{ + GameID: game.GameID, + UserID: owner, + RaceName: "Owner", + }); err != nil { + t.Fatalf("insert membership: %v", err) + } + + if err := svc.DeleteGame(context.Background(), game.GameID); err != nil { + t.Fatalf("delete game: %v", err) + } + + // Verify cascade: the game must be gone, ListMyGames must drop + // it, and re-deleting the same id is a no-op. + if _, err := svc.GetGame(context.Background(), game.GameID); !errors.Is(err, lobby.ErrNotFound) { + t.Fatalf("get after delete: err = %v, want ErrNotFound", err) + } + games, err := svc.ListMyGames(context.Background(), owner) + if err != nil { + t.Fatalf("list my games: %v", err) + } + for _, g := range games { + if g.GameID == game.GameID { + t.Fatalf("ListMyGames still lists the deleted game") + } + } + if err := svc.DeleteGame(context.Background(), game.GameID); err != nil { + t.Fatalf("delete idempotent: %v", err) + } +} + func TestEndToEndPublicGameApplicationApproval(t *testing.T) { db := startPostgres(t) now := time.Now().UTC() diff --git a/backend/internal/lobby/store.go b/backend/internal/lobby/store.go index 27e0af0..97a8c90 100644 --- a/backend/internal/lobby/store.go +++ b/backend/internal/lobby/store.go @@ -232,6 +232,22 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord return modelsToGameRecords(rows) } +// DeleteGame removes the row at gameID. Cascades through every +// referencing table (memberships / applications / invites / +// runtime_records / player_mappings — all declared with ON DELETE +// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row +// matches. Used by the dev-sandbox bootstrap to scrub terminal +// games on every backend boot so the developer's lobby never piles +// up cancelled tiles. +func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error { + g := table.Games + stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID))) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("lobby store: delete game %s: %w", gameID, err) + } + return nil +} + // gameUpdate is the parameter struct for UpdateGame. Nil pointers leave // the corresponding column alone. type gameUpdate struct { diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql index 2a7a0a2..479a64c 100644 --- a/backend/internal/postgres/migrations/00001_init.sql +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -418,7 +418,7 @@ CREATE INDEX race_names_pending_eligible_idx -- finished) and the container-state escape hatch (removed) used by -- reconciliation when the recorded container has disappeared. CREATE TABLE runtime_records ( - game_id uuid PRIMARY KEY, + game_id uuid PRIMARY KEY REFERENCES games (game_id) ON DELETE CASCADE, status text NOT NULL, current_container_id text, current_image_ref text, @@ -465,7 +465,7 @@ CREATE TABLE engine_versions ( -- roster reads. The partial UNIQUE on (game_id, race_name) enforces the -- one-race-per-game invariant at the storage boundary. CREATE TABLE player_mappings ( - game_id uuid NOT NULL, + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, user_id uuid NOT NULL, race_name text NOT NULL, engine_player_uuid uuid NOT NULL, diff --git a/tools/local-dev/README.md b/tools/local-dev/README.md index 142b5bf..8597fb5 100644 --- a/tools/local-dev/README.md +++ b/tools/local-dev/README.md @@ -99,6 +99,16 @@ 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. +Terminal sandbox games — anything in `cancelled`, `finished`, or +`start_failed` — are deleted on every boot before find-or-create +runs. The cascade declared in `00001_init.sql` removes the +matching memberships, applications, invites, runtime records, +and player mappings in the same write, so the dev user's lobby +shows exactly one running tile at all times. Cancelling the +sandbox manually and running `docker compose restart backend` +(or `make rebuild`) yields a fresh game without leaving dead +tiles behind. + The bootstrap requires: - `galaxy-engine:local-dev` Docker image (`make build-engine`). - `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver