009ea560f9
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>
178 lines
5.3 KiB
TypeScript
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();
|