feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Reshape the lobby UI from a single Overview into a two-level sidebar (games · profile · DEV synthetic-reports) with four games sub-panels (active-past · recruitment · invitations · private-games). Move the `create new game` button into the private-games panel, merge the applications section into recruitment cards as status chips, and add DEV-only synthetic-report loader as a top-level screen. Add a paid-tier gate at backend `lobby.game.create`: free callers get `403 forbidden` before the lobby service is invoked. The UI hides the private-games sub-panel + create button on free tier (DEV affordances flag overrides). Update every integration test that creates a game to use a new `testenv.PromoteToPaid` helper; add a new `TestLobbyFlow_FreeUserCreateGameForbidden`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
// LobbyDataStore is the session-wide cache for the four lobby panels
|
||||
// (active-past / recruitment / invitations / private-games). It owns the
|
||||
// GalaxyClient instance used by lobby HTTP commands, the result of the
|
||||
// `lobby.*.list` fan-out, and the loading / error flags every panel
|
||||
// reads. Sub-screens that need to mutate (submit application, redeem
|
||||
// invite) go through the store so the optimistic state stays consistent
|
||||
// across navigations.
|
||||
//
|
||||
// The store is built around F8-04b's split of the old single
|
||||
// `lobby-screen.svelte` into per-panel screens — the prior design fetched
|
||||
// everything on every panel mount, and refetching on each navigation
|
||||
// flash-cleared the UI. A singleton with $state runes keeps the four
|
||||
// lists alive while the user moves between subpages.
|
||||
//
|
||||
// `clear()` resets the store on signOut; the matching plumbing lives in
|
||||
// `session-store.svelte.ts::signOut`.
|
||||
|
||||
import { createGatewayClient } from "../api/connect";
|
||||
import { GalaxyClient } from "../api/galaxy-client";
|
||||
import {
|
||||
LobbyError,
|
||||
listMyApplications,
|
||||
listMyGames,
|
||||
listMyInvites,
|
||||
listPublicGames,
|
||||
type ApplicationSummary,
|
||||
type GameSummary,
|
||||
type InviteSummary,
|
||||
} from "../api/lobby";
|
||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "./env";
|
||||
import { i18n, type TranslationKey } from "./i18n/index.svelte";
|
||||
import { loadCore } from "../platform/core/index";
|
||||
import { session } from "./session-store.svelte";
|
||||
|
||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
export function describeLobbyError(err: unknown): string {
|
||||
if (err instanceof LobbyError) {
|
||||
const key = `lobby.error.${err.code}` as TranslationKey;
|
||||
const translated = i18n.t(key);
|
||||
if (translated !== key) {
|
||||
return translated;
|
||||
}
|
||||
return i18n.t("lobby.error.unknown", { message: err.message });
|
||||
}
|
||||
return err instanceof Error ? err.message : "request failed";
|
||||
}
|
||||
|
||||
class LobbyDataStore {
|
||||
myGames = $state<GameSummary[]>([]);
|
||||
invitations = $state<InviteSummary[]>([]);
|
||||
applications = $state<ApplicationSummary[]>([]);
|
||||
publicGames = $state<GameSummary[]>([]);
|
||||
loading = $state(true);
|
||||
error: string | null = $state(null);
|
||||
configError: string | null = $state(null);
|
||||
|
||||
#client: GalaxyClient | null = null;
|
||||
#bootstrap: Promise<GalaxyClient | null> | null = null;
|
||||
#refresh: Promise<void> | null = null;
|
||||
|
||||
get client(): GalaxyClient | null {
|
||||
return this.#client;
|
||||
}
|
||||
|
||||
// ensure resolves to the cached GalaxyClient, building one on first
|
||||
// call and triggering the initial `lobby.*.list` fan-out. Concurrent
|
||||
// callers from sibling screens share the same in-flight bootstrap.
|
||||
ensure(): Promise<GalaxyClient | null> {
|
||||
if (this.#client !== null) {
|
||||
return Promise.resolve(this.#client);
|
||||
}
|
||||
if (this.#bootstrap !== null) {
|
||||
return this.#bootstrap;
|
||||
}
|
||||
this.#bootstrap = this.#bootstrapClient();
|
||||
return this.#bootstrap;
|
||||
}
|
||||
|
||||
async #bootstrapClient(): Promise<GalaxyClient | null> {
|
||||
try {
|
||||
if (
|
||||
session.keypair === null ||
|
||||
session.deviceSessionId === null ||
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
||||
) {
|
||||
this.loading = false;
|
||||
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
|
||||
this.configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const keypair = session.keypair;
|
||||
const core = await loadCore();
|
||||
this.#client = new GalaxyClient({
|
||||
core,
|
||||
edge: createGatewayClient(gatewayRpcBaseUrl()),
|
||||
signer: (canonical) => keypair.sign(canonical),
|
||||
sha256,
|
||||
deviceSessionId: session.deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
await this.refresh();
|
||||
return this.#client;
|
||||
} catch (err) {
|
||||
this.error = describeLobbyError(err);
|
||||
this.loading = false;
|
||||
return null;
|
||||
} finally {
|
||||
this.#bootstrap = null;
|
||||
}
|
||||
}
|
||||
|
||||
// refresh re-runs the four `lobby.*.list` fan-out. Concurrent callers
|
||||
// share the same in-flight promise.
|
||||
refresh(): Promise<void> {
|
||||
if (this.#client === null) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this.#refresh !== null) {
|
||||
return this.#refresh;
|
||||
}
|
||||
const client = this.#client;
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.#refresh = (async () => {
|
||||
try {
|
||||
const [games, invites, apps, publicPage] = await Promise.all([
|
||||
listMyGames(client),
|
||||
listMyInvites(client),
|
||||
listMyApplications(client),
|
||||
listPublicGames(client),
|
||||
]);
|
||||
this.myGames = games;
|
||||
this.invitations = invites.filter((invite) => invite.status === "pending");
|
||||
this.applications = apps;
|
||||
this.publicGames = publicPage.items;
|
||||
} catch (err) {
|
||||
this.error = describeLobbyError(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.#refresh = null;
|
||||
}
|
||||
})();
|
||||
return this.#refresh;
|
||||
}
|
||||
|
||||
prependApplication(app: ApplicationSummary): void {
|
||||
this.applications = [app, ...this.applications];
|
||||
}
|
||||
|
||||
removeInvitation(inviteId: string): void {
|
||||
this.invitations = this.invitations.filter((i) => i.inviteId !== inviteId);
|
||||
}
|
||||
|
||||
setMyGames(games: GameSummary[]): void {
|
||||
this.myGames = games;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#client = null;
|
||||
this.#bootstrap = null;
|
||||
this.#refresh = null;
|
||||
this.myGames = [];
|
||||
this.invitations = [];
|
||||
this.applications = [];
|
||||
this.publicGames = [];
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.configError = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const lobbyData = new LobbyDataStore();
|
||||
Reference in New Issue
Block a user