diff --git a/backend/internal/devsandbox/bootstrap.go b/backend/internal/devsandbox/bootstrap.go index 30b2716..0e36dcf 100644 --- a/backend/internal/devsandbox/bootstrap.go +++ b/backend/internal/devsandbox/bootstrap.go @@ -146,15 +146,31 @@ func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, } } +// 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 { - return g, nil + 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, diff --git a/backend/internal/devsandbox/bootstrap_test.go b/backend/internal/devsandbox/bootstrap_test.go index 31e7cc6..d812283 100644 --- a/backend/internal/devsandbox/bootstrap_test.go +++ b/backend/internal/devsandbox/bootstrap_test.go @@ -79,6 +79,26 @@ func TestBootstrapRejectsMissingDeps(t *testing.T) { // returned error has a message. 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. +func TestTerminalSandboxStatus(t *testing.T) { + terminal := []string{"cancelled", "finished", "start_failed"} + live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"} + + for _, status := range terminal { + if !terminalSandboxStatus(status) { + t.Errorf("expected %q to be terminal", status) + } + } + for _, status := range live { + if terminalSandboxStatus(status) { + t.Errorf("expected %q to be non-terminal", status) + } + } +} + type stubEnsurer struct{} func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) { diff --git a/tools/local-dev/Makefile b/tools/local-dev/Makefile index 19cd6a5..a36cbbb 100644 --- a/tools/local-dev/Makefile +++ b/tools/local-dev/Makefile @@ -13,11 +13,11 @@ ENGINE_LABEL := org.opencontainers.image.title=galaxy-game-engine help: @echo "Local development stack for the Galaxy UI:" @echo " make up Build (if needed) and bring up the stack, wait until healthy" - @echo " make down Stop containers (incl. spawned engines), keep volumes" + @echo " make down Stop compose containers, leave engines + volumes intact" @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 stop-engines Stop and remove only the per-game engine containers" - @echo " make clean Stop and wipe volumes (postgres data, engines, game state)" + @echo " make clean Stop everything (incl. engines) and wipe volumes + game state" @echo " make logs Tail all logs" @echo " make logs-backend Tail only the backend logs" @echo " make logs-gateway Tail only the gateway logs" @@ -47,7 +47,7 @@ build-engine: docker build -t $(ENGINE_IMAGE) -f $(REPO_ROOT)/game/Dockerfile $(REPO_ROOT); \ fi -down: stop-engines +down: $(COMPOSE) down clean: stop-engines @@ -58,9 +58,11 @@ clean: stop-engines fi # Spawned engine containers run outside the compose project (the -# backend's runtime creates them on demand), so `compose down` does -# not see them. We discover them by the engine image's -# OCI title label, set by game/Dockerfile. +# backend's runtime creates them on demand). They intentionally +# survive `make down` so the runtime reconciler can reattach on the +# next `make up` — killing them out of band makes the runtime +# cascade the game to `cancelled`. We only remove them as part of +# `clean`, where the whole DB is wiped anyway. stop-engines: @ids=$$(docker ps -aq --filter label=$(ENGINE_LABEL)); \ if [ -n "$$ids" ]; then \ diff --git a/tools/local-dev/docker-compose.yml b/tools/local-dev/docker-compose.yml index c450d2b..4dab5b8 100644 --- a/tools/local-dev/docker-compose.yml +++ b/tools/local-dev/docker-compose.yml @@ -167,6 +167,16 @@ services: GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST: "1000" GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS: "10000" GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST: "1000" + # public_misc class wraps the authenticated EdgeGateway gRPC + # endpoints (ExecuteCommand, SubscribeEvents). The gateway's + # default for this class is 0 bytes, which rejects every + # non-empty body with HTTP 413; override with a generous limit + # so browser-side commands carrying signed envelopes go through. + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_MAX_BODY_BYTES: "131072" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST: "1000" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_MAX_BODY_BYTES: "65536" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_MAX_BODY_BYTES: "65536" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS: "10000" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000" @@ -177,6 +187,10 @@ services: GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST: "1000" ports: - "8080:8080" + # Authenticated EdgeGateway connect-web/gRPC listener. The + # browser reaches it via the Vite dev proxy in + # ui/frontend/vite.config.ts. + - "9090:9090" volumes: - ./keys/gateway-response.pem:/run/secrets/gateway-response.pem:ro networks: diff --git a/ui/frontend/src/routes/lobby/+page.svelte b/ui/frontend/src/routes/lobby/+page.svelte index 8abe7af..bbffffa 100644 --- a/ui/frontend/src/routes/lobby/+page.svelte +++ b/ui/frontend/src/routes/lobby/+page.svelte @@ -185,6 +185,16 @@ goto(`/games/${gameId}/map`); } + // Statuses for which the game has a navigable in-game view. + // Lobby-internal statuses (draft, enrollment_open, ready_to_start, + // starting, start_failed) and terminal ones (cancelled) stay + // non-clickable; clicking them otherwise lands on a 404 because + // /games/:id/map only meaningfully exists once the runtime has + // produced game state. + function isPlayableStatus(status: string): boolean { + return status === "running" || status === "paused" || status === "finished"; + } + onMount(async () => { if ( session.keypair === null || @@ -259,6 +269,7 @@