ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
7 changed files with 151 additions and 8 deletions
Showing only changes of commit 6d6a384bee - Show all commits
+38 -3
View File
@@ -106,6 +106,10 @@ func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logg
dummyIDs = append(dummyIDs, id) dummyIDs = append(dummyIDs, id)
} }
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
return err
}
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg) game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
if err != nil { if err != nil {
return err return err
@@ -158,6 +162,37 @@ func terminalSandboxStatus(status string) bool {
return false 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) { func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
games, err := svc.ListMyGames(ctx, ownerID) games, err := svc.ListMyGames(ctx, ownerID)
if err != nil { 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 { if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
continue continue
} }
if terminalSandboxStatus(g.Status) { // `purgeTerminalSandboxGames` ran before us, so any sandbox
continue // game still in the list is either a live one we should
} // reuse or a transient state we can drive forward.
return g, nil return g, nil
} }
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{ rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
@@ -80,9 +80,9 @@ func TestBootstrapRejectsMissingDeps(t *testing.T) {
var errMissingDepsSentinel = errors.New("sentinel") var errMissingDepsSentinel = errors.New("sentinel")
// TestTerminalSandboxStatus pins the contract that decides whether a // TestTerminalSandboxStatus pins the contract that decides whether a
// previously created sandbox game is reusable. Terminal states force // previously created sandbox game gets purged on the next boot.
// the bootstrap to create a new game on the next boot rather than // Terminal states are deleted (cascade-style) so the developer's
// hand the developer a dead lobby tile. // lobby never piles up dead tiles between `make rebuild` cycles.
func TestTerminalSandboxStatus(t *testing.T) { func TestTerminalSandboxStatus(t *testing.T) {
terminal := []string{"cancelled", "finished", "start_failed"} terminal := []string{"cancelled", "finished", "start_failed"}
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"} live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
+18
View File
@@ -233,6 +233,24 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco
return s.deps.Store.ListMyGames(ctx, userID) 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 // State-machine transition handlers below take the same shape: load the
// game (cache or store), check owner, validate the current status, run // game (cache or store), check owner, validate the current status, run
// the transition write, refresh the cache, optionally tell the runtime // the transition write, refresh the cache, optionally tell the runtime
+64
View File
@@ -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) { func TestEndToEndPublicGameApplicationApproval(t *testing.T) {
db := startPostgres(t) db := startPostgres(t)
now := time.Now().UTC() now := time.Now().UTC()
+16
View File
@@ -232,6 +232,22 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
return modelsToGameRecords(rows) 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 // gameUpdate is the parameter struct for UpdateGame. Nil pointers leave
// the corresponding column alone. // the corresponding column alone.
type gameUpdate struct { type gameUpdate struct {
@@ -418,7 +418,7 @@ CREATE INDEX race_names_pending_eligible_idx
-- finished) and the container-state escape hatch (removed) used by -- finished) and the container-state escape hatch (removed) used by
-- reconciliation when the recorded container has disappeared. -- reconciliation when the recorded container has disappeared.
CREATE TABLE runtime_records ( 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, status text NOT NULL,
current_container_id text, current_container_id text,
current_image_ref 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 -- roster reads. The partial UNIQUE on (game_id, race_name) enforces the
-- one-race-per-game invariant at the storage boundary. -- one-race-per-game invariant at the storage boundary.
CREATE TABLE player_mappings ( 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, user_id uuid NOT NULL,
race_name text NOT NULL, race_name text NOT NULL,
engine_player_uuid uuid NOT NULL, engine_player_uuid uuid NOT NULL,
+10
View File
@@ -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 `tools/local-dev/.env` and `docker compose up -d backend` (or
`make rebuild`). Existing users / games are not removed. `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: The bootstrap requires:
- `galaxy-engine:local-dev` Docker image (`make build-engine`). - `galaxy-engine:local-dev` Docker image (`make build-engine`).
- `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver - `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver