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 123 additions and 12 deletions
Showing only changes of commit 0f8f8698bd - Show all commits
+18 -2
View File
@@ -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) { 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 {
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err) return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err)
} }
for _, g := range games { for _, g := range games {
if g.GameName == SandboxGameName && g.OwnerUserID != nil && *g.OwnerUserID == ownerID { if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
return g, nil continue
} }
if terminalSandboxStatus(g.Status) {
continue
}
return g, nil
} }
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{ rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
OwnerUserID: &ownerID, OwnerUserID: &ownerID,
@@ -79,6 +79,26 @@ func TestBootstrapRejectsMissingDeps(t *testing.T) {
// returned error has a message. // returned error has a message.
var errMissingDepsSentinel = errors.New("sentinel") 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{} type stubEnsurer struct{}
func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) { func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) {
+8 -6
View File
@@ -13,11 +13,11 @@ ENGINE_LABEL := org.opencontainers.image.title=galaxy-game-engine
help: help:
@echo "Local development stack for the Galaxy UI:" @echo "Local development stack for the Galaxy UI:"
@echo " make up Build (if needed) and bring up the stack, wait until healthy" @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 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 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 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 Tail all logs"
@echo " make logs-backend Tail only the backend logs" @echo " make logs-backend Tail only the backend logs"
@echo " make logs-gateway Tail only the gateway 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); \ docker build -t $(ENGINE_IMAGE) -f $(REPO_ROOT)/game/Dockerfile $(REPO_ROOT); \
fi fi
down: stop-engines down:
$(COMPOSE) down $(COMPOSE) down
clean: stop-engines clean: stop-engines
@@ -58,9 +58,11 @@ clean: stop-engines
fi fi
# Spawned engine containers run outside the compose project (the # Spawned engine containers run outside the compose project (the
# backend's runtime creates them on demand), so `compose down` does # backend's runtime creates them on demand). They intentionally
# not see them. We discover them by the engine image's # survive `make down` so the runtime reconciler can reattach on the
# OCI title label, set by game/Dockerfile. # 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: stop-engines:
@ids=$$(docker ps -aq --filter label=$(ENGINE_LABEL)); \ @ids=$$(docker ps -aq --filter label=$(ENGINE_LABEL)); \
if [ -n "$$ids" ]; then \ if [ -n "$$ids" ]; then \
+14
View File
@@ -167,6 +167,16 @@ services:
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST: "1000" 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_REQUESTS: "10000"
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST: "1000" 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_REQUESTS: "10000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000" 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" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST: "1000"
ports: ports:
- "8080:8080" - "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: volumes:
- ./keys/gateway-response.pem:/run/secrets/gateway-response.pem:ro - ./keys/gateway-response.pem:/run/secrets/gateway-response.pem:ro
networks: networks:
+17
View File
@@ -185,6 +185,16 @@
goto(`/games/${gameId}/map`); 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 () => { onMount(async () => {
if ( if (
session.keypair === null || session.keypair === null ||
@@ -259,6 +269,7 @@
<button <button
class="card" class="card"
onclick={() => gotoGame(game.gameId)} onclick={() => gotoGame(game.gameId)}
disabled={!isPlayableStatus(game.status)}
data-testid="lobby-my-game-card" data-testid="lobby-my-game-card"
> >
<strong>{game.gameName}</strong> <strong>{game.gameName}</strong>
@@ -448,6 +459,12 @@
width: 100%; width: 100%;
} }
button.card:disabled {
cursor: not-allowed;
color: #777;
background: #f0f0f0;
}
li.card { li.card {
cursor: default; cursor: default;
} }
+35
View File
@@ -339,6 +339,41 @@ describe("lobby page", () => {
}); });
}); });
test("my-game cards are clickable for running/paused/finished and disabled otherwise", async () => {
// Cover the live-able statuses (running, paused, finished) and a
// representative non-playable mix (cancelled is the post-shutdown
// terminal state developers see most often; draft is the lobby-
// internal state before any membership exists).
listMyGamesSpy.mockResolvedValue([
makeGame("g-running", "Live", "running"),
makeGame("g-paused", "Paused Run", "paused"),
makeGame("g-finished", "Closed Run", "finished"),
makeGame("g-cancelled", "Cancelled Run", "cancelled"),
makeGame("g-draft", "Draft Run", "draft"),
]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(5);
});
const cards = ui.getAllByTestId("lobby-my-game-card");
const disabledByLabel: Record<string, boolean> = {};
for (const card of cards) {
const label = card.querySelector("strong")?.textContent ?? "";
disabledByLabel[label] = (card as HTMLButtonElement).disabled;
}
expect(disabledByLabel["Live"]).toBe(false);
expect(disabledByLabel["Paused Run"]).toBe(false);
expect(disabledByLabel["Closed Run"]).toBe(false);
expect(disabledByLabel["Cancelled Run"]).toBe(true);
expect(disabledByLabel["Draft Run"]).toBe(true);
});
test("application status badges localise pending and approved states", async () => { test("application status badges localise pending and approved states", async () => {
listMyGamesSpy.mockResolvedValue([]); listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 }); listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
+11 -4
View File
@@ -10,11 +10,18 @@ const pkg = JSON.parse(
), ),
) as { version: string }; ) as { version: string };
// Default upstream gateway address used by the dev proxy. Override by // Default upstream gateway addresses used by the dev proxy. Override
// pointing `VITE_DEV_PROXY_TARGET` at a different gateway when working // by pointing `VITE_DEV_PROXY_TARGET` (REST surface) and
// with a remote stack instead of `tools/local-dev/`. // `VITE_DEV_GRPC_PROXY_TARGET` (Connect-Web surface) at a different
// gateway when working with a remote stack instead of
// `tools/local-dev/`. In production the two surfaces sit behind a
// single host; the split here exists only because local-dev runs the
// REST listener on :8080 and the authenticated Connect-Web listener
// on :9090.
const DEV_PROXY_TARGET = const DEV_PROXY_TARGET =
process.env.VITE_DEV_PROXY_TARGET ?? "http://localhost:8080"; process.env.VITE_DEV_PROXY_TARGET ?? "http://localhost:8080";
const DEV_GRPC_PROXY_TARGET =
process.env.VITE_DEV_GRPC_PROXY_TARGET ?? "http://localhost:9090";
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
@@ -33,7 +40,7 @@ export default defineConfig({
changeOrigin: false, changeOrigin: false,
}, },
"/galaxy.gateway.v1.EdgeGateway": { "/galaxy.gateway.v1.EdgeGateway": {
target: DEV_PROXY_TARGET, target: DEV_GRPC_PROXY_TARGET,
changeOrigin: false, changeOrigin: false,
// Connect-Web server-streaming (`SubscribeEvents`) uses // Connect-Web server-streaming (`SubscribeEvents`) uses
// chunked HTTP responses; http-proxy passes them through // chunked HTTP responses; http-proxy passes them through