ui: plan 01-27 done #1
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 @@
|
||||
<button
|
||||
class="card"
|
||||
onclick={() => gotoGame(game.gameId)}
|
||||
disabled={!isPlayableStatus(game.status)}
|
||||
data-testid="lobby-my-game-card"
|
||||
>
|
||||
<strong>{game.gameName}</strong>
|
||||
@@ -448,6 +459,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button.card:disabled {
|
||||
cursor: not-allowed;
|
||||
color: #777;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
li.card {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
listMyGamesSpy.mockResolvedValue([]);
|
||||
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
|
||||
@@ -10,11 +10,18 @@ const pkg = JSON.parse(
|
||||
),
|
||||
) as { version: string };
|
||||
|
||||
// Default upstream gateway address used by the dev proxy. Override by
|
||||
// pointing `VITE_DEV_PROXY_TARGET` at a different gateway when working
|
||||
// with a remote stack instead of `tools/local-dev/`.
|
||||
// Default upstream gateway addresses used by the dev proxy. Override
|
||||
// by pointing `VITE_DEV_PROXY_TARGET` (REST surface) and
|
||||
// `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 =
|
||||
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({
|
||||
plugins: [sveltekit()],
|
||||
@@ -33,7 +40,7 @@ export default defineConfig({
|
||||
changeOrigin: false,
|
||||
},
|
||||
"/galaxy.gateway.v1.EdgeGateway": {
|
||||
target: DEV_PROXY_TARGET,
|
||||
target: DEV_GRPC_PROXY_TARGET,
|
||||
changeOrigin: false,
|
||||
// Connect-Web server-streaming (`SubscribeEvents`) uses
|
||||
// chunked HTTP responses; http-proxy passes them through
|
||||
|
||||
Reference in New Issue
Block a user