Files
galaxy-game/ui/frontend/src/lib/lobby-data.svelte.ts
T
Ilia Denisov 009ea560f9
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run
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>
2026-05-26 23:53:53 +02:00

178 lines
5.3 KiB
TypeScript

// 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();