feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run

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:
Ilia Denisov
2026-05-26 23:53:53 +02:00
parent 98d1fe6cae
commit 009ea560f9
44 changed files with 2486 additions and 1118 deletions
+19 -1
View File
@@ -40,6 +40,16 @@ export interface Account {
preferredLanguage: string;
timeZone: string;
declaredCountry: string;
entitlement: AccountEntitlement;
}
// AccountEntitlement is the narrow view of the FBS EntitlementSnapshot
// the UI currently consumes. `isPaid` gates lobby affordances tied to
// the paid tier (F8-04b: private-games subpage + create-game button).
// Other snapshot fields (plan code, expiry timestamps) are intentionally
// omitted until a feature needs them.
export interface AccountEntitlement {
isPaid: boolean;
}
export async function getMyAccount(client: GalaxyClient): Promise<Account> {
@@ -119,7 +129,12 @@ function decodeAccountResponse(payload: Uint8Array): Account {
return decodeAccountView(view);
}
function decodeAccountView(view: AccountView): Account {
// Exported for unit tests that build a synthetic AccountView via the
// FBS bindings and assert the resulting Account shape. Runtime callers
// reach the same decode path through `getMyAccount` / `updateMyProfile`
// / `updateMySettings`.
export function decodeAccountView(view: AccountView): Account {
const entitlement = view.entitlement();
return {
userId: view.userId() ?? "",
email: view.email() ?? "",
@@ -128,5 +143,8 @@ function decodeAccountView(view: AccountView): Account {
preferredLanguage: view.preferredLanguage() ?? "",
timeZone: view.timeZone() ?? "",
declaredCountry: view.declaredCountry() ?? "",
entitlement: {
isPaid: entitlement?.isPaid() ?? false,
},
};
}
+11 -1
View File
@@ -7,7 +7,17 @@ declare global {
// (and active game) live in `page.state` so browser Back/Forward
// move between screens while the address bar stays at /game/.
interface PageState {
screen?: "login" | "lobby" | "lobby-create" | "profile" | "game";
screen?:
| "login"
| "lobby"
| "lobby-create"
| "profile"
| "game"
| "games-active-past"
| "games-recruitment"
| "games-invitations"
| "games-private-games"
| "synthetic-reports";
gameId?: string | null;
}
}
+42 -1
View File
@@ -22,7 +22,43 @@
import { pushState, replaceState } from "$app/navigation";
export type AppScreen = "login" | "lobby" | "lobby-create" | "profile" | "game";
// Top-level app-shell screens. The lobby is split into per-page screens
// (F8-04b): `lobby` is the bare alias the shell resolves to the first
// visible games sub-page; the explicit `games-*` literals point at one
// of the four lobby sub-panels; `synthetic-reports` is the DEV-only
// reports screen (build-time gated via VITE_GALAXY_DEV_AFFORDANCES).
// `lobby-create` and `profile` remain as separate top-level screens.
export type AppScreen =
| "login"
| "lobby"
| "lobby-create"
| "profile"
| "game"
| "games-active-past"
| "games-recruitment"
| "games-invitations"
| "games-private-games"
| "synthetic-reports";
// LOBBY_SUB_SCREENS lists the AppScreen literals that the sidebar
// renders as members of the `games` submenu. The order is the canonical
// "first-visible" order: when the user clicks the `games` header (or
// the screen state is the bare `lobby` alias) the shell picks the
// first entry whose visibility predicate holds.
export const LOBBY_SUB_SCREENS: readonly AppScreen[] = [
"games-active-past",
"games-recruitment",
"games-invitations",
"games-private-games",
];
// isLobbySubScreen returns true when screen identifies one of the
// `games-*` sub-panels — useful for the sidebar to highlight the
// `games` parent and to decide whether the desktop submenu should be
// rendered expanded.
export function isLobbySubScreen(screen: AppScreen): boolean {
return LOBBY_SUB_SCREENS.includes(screen);
}
export type GameView =
| "map"
@@ -53,6 +89,11 @@ const APP_SCREENS: readonly AppScreen[] = [
"lobby-create",
"profile",
"game",
"games-active-past",
"games-recruitment",
"games-invitations",
"games-private-games",
"synthetic-reports",
];
const GAME_VIEWS: readonly GameView[] = [
"map",
+17
View File
@@ -55,16 +55,31 @@ const en = {
"lobby.nav.aria_label": "lobby pages",
"lobby.nav.overview": "Overview",
"lobby.nav.profile": "Profile",
"lobby.nav.games": "games",
"lobby.nav.games.active_past": "active & past",
"lobby.nav.games.recruitment": "recruitment",
"lobby.nav.games.invitations": "invitations",
"lobby.nav.games.private_games": "private games",
"lobby.nav.games.aria_label": "games sections",
"lobby.nav.games.mobile_toggle": "games · {label}",
"lobby.nav.synthetic_reports": "Synthetic test reports",
"lobby.section.my_games": "my games",
"lobby.section.invitations": "pending invitations",
"lobby.section.applications": "my applications",
"lobby.section.public_games": "public games",
"lobby.section.recruitment": "open recruitment",
"lobby.section.private_games": "my private games",
"lobby.section.create": "create a game",
"lobby.create_button": "create new game",
"lobby.my_games.empty": "no games yet",
"lobby.invitations.empty": "no invitations",
"lobby.applications.empty": "no applications",
"lobby.public_games.empty": "no public games",
"lobby.games.active_past.empty": "no active or past games",
"lobby.games.private_games.empty": "no private games yet",
"lobby.recruitment.empty": "no open recruitment",
"lobby.recruitment.applied_pending": "your application is awaiting approval",
"lobby.recruitment.applied_approved": "your application was accepted",
"lobby.invitation.accept": "accept",
"lobby.invitation.decline": "decline",
"lobby.application.submit": "submit application",
@@ -96,6 +111,8 @@ const en = {
"lobby.create.game_name_required": "game name must not be empty",
"lobby.create.turn_schedule_required": "turn schedule must not be empty",
"lobby.create.enrollment_ends_at_required": "enrollment end time must be set",
"lobby.create.error.forbidden":
"Game creation is available only on a paid plan.",
"lobby.error.invalid_request": "request is invalid",
"lobby.error.subject_not_found": "not found",
"lobby.error.forbidden": "operation is forbidden",
+17
View File
@@ -56,16 +56,31 @@ const ru: Record<keyof typeof en, string> = {
"lobby.nav.aria_label": "разделы лобби",
"lobby.nav.overview": "Обзор",
"lobby.nav.profile": "Профиль",
"lobby.nav.games": "партии",
"lobby.nav.games.active_past": "активные и прошедшие",
"lobby.nav.games.recruitment": "набор",
"lobby.nav.games.invitations": "приглашения",
"lobby.nav.games.private_games": "приватные партии",
"lobby.nav.games.aria_label": "подразделы партий",
"lobby.nav.games.mobile_toggle": "партии · {label}",
"lobby.nav.synthetic_reports": "Synthetic-отчёты",
"lobby.section.my_games": "мои игры",
"lobby.section.invitations": "ожидающие приглашения",
"lobby.section.applications": "мои заявки",
"lobby.section.public_games": "публичные игры",
"lobby.section.recruitment": "открытый набор",
"lobby.section.private_games": "мои приватные партии",
"lobby.section.create": "создать игру",
"lobby.create_button": "создать новую игру",
"lobby.my_games.empty": "пока нет игр",
"lobby.invitations.empty": "приглашений нет",
"lobby.applications.empty": "заявок нет",
"lobby.public_games.empty": "публичных игр нет",
"lobby.games.active_past.empty": "нет активных или прошедших партий",
"lobby.games.private_games.empty": "у вас нет собственных партий",
"lobby.recruitment.empty": "набор в партии ещё не открыт",
"lobby.recruitment.applied_pending": "ваша заявка ожидает одобрения",
"lobby.recruitment.applied_approved": "ваша заявка принята",
"lobby.invitation.accept": "принять",
"lobby.invitation.decline": "отклонить",
"lobby.application.submit": "подать заявку",
@@ -97,6 +112,8 @@ const ru: Record<keyof typeof en, string> = {
"lobby.create.game_name_required": "название игры не должно быть пустым",
"lobby.create.turn_schedule_required": "расписание ходов не должно быть пустым",
"lobby.create.enrollment_ends_at_required": "время окончания набора обязательно",
"lobby.create.error.forbidden":
"Создание партий доступно только на платном тарифе.",
"lobby.error.invalid_request": "запрос некорректен",
"lobby.error.subject_not_found": "объект не найден",
"lobby.error.forbidden": "операция запрещена",
+177
View File
@@ -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();
@@ -0,0 +1,111 @@
<!--
Active & past games panel for the lobby `games` section. Lists every
game where the caller is a member, regardless of lifecycle status.
Statuses that have no navigable in-game view (draft / enrollment_open /
starting / cancelled / start_failed) render as disabled cards.
The shell hides this submenu item entirely when the player has no games,
so the empty-state text below is reached only in the narrow window
where the shell mounts before the lobby-data fan-out resolves.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { account } from "$lib/account-store.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished";
}
function gotoGame(gameId: string): void {
activeView.reset();
appScreen.go("game", { gameId });
}
</script>
<LobbyShell>
{#if lobbyData.configError !== null}
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
{:else if lobbyData.error !== null}
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
{/if}
<section data-testid="lobby-games-active-past-section">
<h2>{i18n.t("lobby.section.my_games")}</h2>
{#if lobbyData.loading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if lobbyData.myGames.length === 0}
<p data-testid="lobby-games-active-past-empty">
{i18n.t("lobby.games.active_past.empty")}
</p>
{:else}
<ul class="card-list">
{#each lobbyData.myGames as game (game.gameId)}
<li>
<button
class="card"
onclick={() => gotoGame(game.gameId)}
disabled={!isPlayableStatus(game.status)}
data-testid="lobby-my-game-card"
>
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell>
<style>
section {
margin-bottom: var(--space-6);
}
section h2 {
font-size: var(--text-lg);
margin: 0 0 var(--space-3);
}
.card-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
cursor: pointer;
width: 100%;
}
button.card:disabled {
cursor: not-allowed;
color: var(--color-text-faint);
background: var(--color-surface);
}
.meta {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
</style>
@@ -0,0 +1,127 @@
<!--
Pending invitations panel for the lobby `games` section. Surfaces
user-bound invites that have not been redeemed or declined yet, with
accept / decline actions. Accepted invites move the inviting game into
`active-past`; declined invites disappear from the list.
-->
<script lang="ts">
import { onMount } from "svelte";
import { account } from "$lib/account-store.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { lobbyData, describeLobbyError } from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
import {
declineInvite,
listMyGames,
redeemInvite,
type InviteSummary,
} from "../../api/lobby";
let actionInFlight = $state<string | null>(null);
let actionError = $state<string | null>(null);
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
async function acceptInvite(invite: InviteSummary): Promise<void> {
const client = lobbyData.client;
if (client === null) return;
actionInFlight = invite.inviteId;
actionError = null;
try {
await redeemInvite(client, invite.gameId, invite.inviteId);
lobbyData.removeInvitation(invite.inviteId);
lobbyData.setMyGames(await listMyGames(client));
} catch (err) {
actionError = describeLobbyError(err);
} finally {
actionInFlight = null;
}
}
async function rejectInvite(invite: InviteSummary): Promise<void> {
const client = lobbyData.client;
if (client === null) return;
actionInFlight = invite.inviteId;
actionError = null;
try {
await declineInvite(client, invite.gameId, invite.inviteId);
lobbyData.removeInvitation(invite.inviteId);
} catch (err) {
actionError = describeLobbyError(err);
} finally {
actionInFlight = null;
}
}
</script>
<LobbyShell>
{#if lobbyData.configError !== null}
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
{:else if lobbyData.error !== null}
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
{/if}
{#if actionError !== null}
<p role="alert" data-testid="lobby-invitation-error">{actionError}</p>
{/if}
<section data-testid="lobby-games-invitations-section">
<h2>{i18n.t("lobby.section.invitations")}</h2>
{#if lobbyData.loading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if lobbyData.invitations.length === 0}
<p data-testid="lobby-invitations-empty">{i18n.t("lobby.invitations.empty")}</p>
{:else}
<ul class="card-list">
{#each lobbyData.invitations as invite (invite.inviteId)}
<li class="card">
<strong>{invite.raceName}</strong>
<span class="meta">{invite.gameId}</span>
<div class="actions">
<button
onclick={() => acceptInvite(invite)}
disabled={actionInFlight === invite.inviteId}
data-testid="lobby-invite-accept"
>
{i18n.t("lobby.invitation.accept")}
</button>
<button
onclick={() => rejectInvite(invite)}
disabled={actionInFlight === invite.inviteId}
data-testid="lobby-invite-decline"
>
{i18n.t("lobby.invitation.decline")}
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell>
<style>
section { margin-bottom: var(--space-6); }
section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
.card-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
width: 100%;
}
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
.actions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
</style>
@@ -0,0 +1,137 @@
<!--
Private games panel for the lobby `games` section. Filters
`lobby.my.games.list` down to games where the caller is the owner and
the visibility is `private`. The right-hand corner of the panel hosts
the "create new game" button that opens `lobby-create`.
This subpage is gated by paid-tier visibility (the shell hides the
sidebar entry on free-tier; the backend also rejects
`lobby.game.create` with `403 forbidden` for free callers). The
`VITE_GALAXY_DEV_AFFORDANCES` flag is the DEV bypass — the shell
keeps the entry visible so the owner can exercise both paths from a
single test account.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { account } from "$lib/account-store.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished";
}
function gotoGame(gameId: string): void {
activeView.reset();
appScreen.go("game", { gameId });
}
function gotoCreate(): void {
appScreen.go("lobby-create");
}
let privateGames = $derived.by(() => {
const me = account.current?.userId ?? "";
return lobbyData.myGames.filter(
(g) => g.ownerUserId === me && g.gameType === "private",
);
});
</script>
<LobbyShell>
{#if lobbyData.configError !== null}
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
{:else if lobbyData.error !== null}
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
{/if}
<section data-testid="lobby-games-private-section">
<header class="section-header">
<h2>{i18n.t("lobby.section.private_games")}</h2>
<button
type="button"
class="create-button"
onclick={gotoCreate}
data-testid="lobby-create-button"
>
{i18n.t("lobby.create_button")}
</button>
</header>
{#if lobbyData.loading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if privateGames.length === 0}
<p data-testid="lobby-games-private-empty">
{i18n.t("lobby.games.private_games.empty")}
</p>
{:else}
<ul class="card-list">
{#each privateGames as game (game.gameId)}
<li>
<button
class="card"
onclick={() => gotoGame(game.gameId)}
disabled={!isPlayableStatus(game.status)}
data-testid="lobby-private-game-card"
>
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell>
<style>
section { margin-bottom: var(--space-6); }
section h2 { font-size: var(--text-lg); margin: 0; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.create-button {
font: inherit;
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
cursor: pointer;
}
.create-button:hover { background: var(--color-surface-hover); }
.card-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
cursor: pointer;
width: 100%;
}
button.card:disabled {
cursor: not-allowed;
color: var(--color-text-faint);
background: var(--color-surface);
}
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
</style>
@@ -0,0 +1,291 @@
<!--
Recruitment panel for the lobby `games` section. Surfaces every public
game open for enrollment plus the caller's own pending/approved
applications for games whose enrollment has since closed. Cards merge
the public-game summary with the caller's application (if any) and
display the status as a chip in the top-right.
Inline race-name form behaviour:
- visible when the caller has no application for that game;
- visible when the caller's last application was rejected (re-apply
flow, owner-confirmed in F8-04b);
- hidden when the application is pending or approved.
Cards for stale applications (enrollment closed, no public-game row):
- pending/approved → rendered as a standalone "applied" card so the
caller can see their waitlist or accepted slot;
- rejected/unknown → not rendered (no actionable info).
-->
<script lang="ts">
import { onMount } from "svelte";
import { account } from "$lib/account-store.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
lobbyData,
describeLobbyError,
} from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
import {
submitApplication,
type ApplicationSummary,
type GameSummary,
} from "../../api/lobby";
let openApplicationFor = $state<string | null>(null);
let raceNameInput = $state("");
let raceNameError = $state<string | null>(null);
let submitting = $state(false);
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
function applicationStatusLabel(status: string): string {
const key = `lobby.application.status.${status}` as TranslationKey;
const translated = i18n.t(key);
if (translated === key) {
return i18n.t("lobby.application.status.unknown", { status });
}
return translated;
}
// Determine whether the inline race-name form should be visible for
// a given (game, application) pair. Visible when there's no
// application, or when the latest application was rejected so the
// caller can try again. Hidden for pending / approved / unknown.
function showApplicationForm(application: ApplicationSummary | null): boolean {
if (application === null) return true;
return application.status === "rejected";
}
interface RecruitmentCard {
game: GameSummary;
application: ApplicationSummary | null;
}
let appsByGameId = $derived.by<Map<string, ApplicationSummary>>(() => {
const m = new Map<string, ApplicationSummary>();
for (const app of lobbyData.applications) {
m.set(app.gameId, app);
}
return m;
});
let recruitmentCards = $derived.by<ReadonlyArray<RecruitmentCard>>(() => {
const me = account.current?.userId ?? "";
return lobbyData.publicGames
.filter((g) => g.ownerUserId !== me)
.map((g) => ({ game: g, application: appsByGameId.get(g.gameId) ?? null }));
});
// Standalone cards for stale applications (game no longer in the
// public-games list but the application is pending/approved).
// Rejected / unknown stale applications are intentionally hidden.
let standaloneApplications = $derived.by<ReadonlyArray<ApplicationSummary>>(() => {
const seen = new Set(lobbyData.publicGames.map((g) => g.gameId));
return lobbyData.applications.filter(
(app) =>
!seen.has(app.gameId) &&
(app.status === "pending" || app.status === "approved"),
);
});
function openApplicationForm(gameId: string): void {
openApplicationFor = gameId;
raceNameInput = "";
raceNameError = null;
}
function cancelApplicationForm(): void {
openApplicationFor = null;
raceNameInput = "";
raceNameError = null;
}
async function submitApplicationFor(gameId: string): Promise<void> {
const client = lobbyData.client;
if (client === null) return;
const trimmed = raceNameInput.trim();
if (trimmed === "") {
raceNameError = i18n.t("lobby.application.race_name_required");
return;
}
submitting = true;
raceNameError = null;
try {
const result = await submitApplication(client, gameId, trimmed);
lobbyData.prependApplication(result);
openApplicationFor = null;
raceNameInput = "";
} catch (err) {
raceNameError = describeLobbyError(err);
} finally {
submitting = false;
}
}
</script>
<LobbyShell>
{#if lobbyData.configError !== null}
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
{:else if lobbyData.error !== null}
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
{/if}
<section data-testid="lobby-games-recruitment-section">
<h2>{i18n.t("lobby.section.recruitment")}</h2>
{#if lobbyData.loading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if recruitmentCards.length === 0 && standaloneApplications.length === 0}
<p data-testid="lobby-recruitment-empty">
{i18n.t("lobby.recruitment.empty")}
</p>
{:else}
<ul class="card-list">
{#each recruitmentCards as card (card.game.gameId)}
<li class="card" data-testid="lobby-recruitment-card">
<div class="card-header">
<strong>{card.game.gameName}</strong>
{#if card.application !== null}
<span
class="status-chip"
data-status={card.application.status}
data-testid="lobby-application-status-chip"
>
{applicationStatusLabel(card.application.status)}
</span>
{/if}
</div>
<span class="meta">{card.game.status}</span>
<span class="meta">{card.game.minPlayers}{card.game.maxPlayers} players</span>
{#if showApplicationForm(card.application)}
{#if openApplicationFor === card.game.gameId}
<form
onsubmit={(event) => {
event.preventDefault();
submitApplicationFor(card.game.gameId);
}}
data-testid="lobby-application-form"
>
<label>
{i18n.t("lobby.application.race_name_label")}
<input
type="text"
bind:value={raceNameInput}
data-testid="lobby-application-race-name"
autocomplete="off"
/>
</label>
{#if raceNameError !== null}
<p role="alert" data-testid="lobby-application-error">
{raceNameError}
</p>
{/if}
<div class="actions">
<button
type="submit"
disabled={submitting}
data-testid="lobby-application-submit"
>
{i18n.t("lobby.application.submit")}
</button>
<button
type="button"
onclick={cancelApplicationForm}
data-testid="lobby-application-cancel"
>
{i18n.t("lobby.application.cancel")}
</button>
</div>
</form>
{:else}
<button
onclick={() => openApplicationForm(card.game.gameId)}
data-testid="lobby-public-game-apply"
>
{i18n.t("lobby.application.submit_for", {
name: card.game.gameName,
})}
</button>
{/if}
{:else if card.application?.status === "pending"}
<p class="meta">{i18n.t("lobby.recruitment.applied_pending")}</p>
{:else if card.application?.status === "approved"}
<p class="meta">{i18n.t("lobby.recruitment.applied_approved")}</p>
{/if}
</li>
{/each}
{#each standaloneApplications as app (app.applicationId)}
<li class="card" data-testid="lobby-stale-application-card">
<div class="card-header">
<strong>{app.raceName}</strong>
<span
class="status-chip"
data-status={app.status}
data-testid="lobby-application-status-chip"
>
{applicationStatusLabel(app.status)}
</span>
</div>
<span class="meta">{app.gameId}</span>
{#if app.status === "pending"}
<p class="meta">{i18n.t("lobby.recruitment.applied_pending")}</p>
{:else}
<p class="meta">{i18n.t("lobby.recruitment.applied_approved")}</p>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
</LobbyShell>
<style>
section { margin-bottom: var(--space-6); }
section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
.card-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
width: 100%;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.status-chip {
align-self: flex-start;
padding: 0.1rem var(--space-2);
border-radius: var(--radius-pill);
background: var(--color-surface);
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: lowercase;
}
.status-chip[data-status="approved"] {
color: var(--color-accent);
}
.status-chip[data-status="rejected"] {
color: var(--color-danger, #c33);
}
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
.actions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
form { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-2); }
label { display: flex; flex-direction: column; gap: var(--space-1); }
input[type="text"] { font: inherit; font-size: var(--text-md); padding: var(--space-1) var(--space-2); }
</style>
@@ -40,6 +40,15 @@
function describeLobbyError(err: unknown): string {
if (err instanceof LobbyError) {
// Free-tier callers reach this branch when the backend gate
// at `lobby.game.create` rejects them. Show the dedicated
// inline message instead of the generic "operation
// forbidden" — the user got to this screen via the
// `private games` panel, so we want to spell out that the
// gate is the tier (not a permission misconfig).
if (err.code === "forbidden") {
return i18n.t("lobby.create.error.forbidden");
}
const key = `lobby.error.${err.code}` as TranslationKey;
const translated = i18n.t(key);
if (translated !== key) {
@@ -93,7 +102,10 @@
turnSchedule: trimmedSchedule,
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
});
appScreen.go("lobby");
// Land on the private-games panel where the freshly created
// game shows up — the lobby-data store will refresh on next
// mount.
appScreen.go("games-private-games");
} catch (err) {
formError = describeLobbyError(err);
} finally {
+24 -536
View File
@@ -1,544 +1,32 @@
<!--
Resolver for the bare `lobby` AppScreen literal. F8-04b split the
old single-page Overview into per-panel screens (`games-active-past`,
`games-recruitment`, `games-invitations`, `games-private-games`); this
component is what the dispatcher renders when the active screen is
the historical `lobby` alias (e.g. a snapshot persisted before the
split, or programmatic `appScreen.go("lobby")` from non-shell code).
The resolver navigates to the first visible games sub-page at mount
time and renders a thin LobbyShell placeholder while the redirect
runs. The destination depends on the account tier and on whether the
caller has any games yet — both are computed by the shell, so we just
pick `games-recruitment` here as the canonical landing (always
visible) and let the shell's submenu logic surface the others.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import {
LobbyError,
listMyApplications,
listMyGames,
listMyInvites,
listPublicGames,
redeemInvite,
declineInvite,
submitApplication,
type ApplicationSummary,
type GameSummary,
type InviteSummary,
} from "../../api/lobby";
import { account } from "$lib/account-store.svelte";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
SyntheticReportError,
loadSyntheticReportFromJSON,
} from "../../api/synthetic-report";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import LobbyShell from "./lobby-shell.svelte";
let configError: string | null = $state(null);
let listsLoading = $state(true);
let lobbyError: string | null = $state(null);
let myGames: GameSummary[] = $state([]);
let invitations: InviteSummary[] = $state([]);
let applications: ApplicationSummary[] = $state([]);
let publicGames: GameSummary[] = $state([]);
let openApplicationFor: string | null = $state(null);
let raceNameInput = $state("");
let raceNameError: string | null = $state(null);
let submittingApplication = $state(false);
let inviteActionInFlight: string | null = $state(null);
let syntheticError: string | null = $state(null);
let client: GalaxyClient | null = null;
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
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";
}
async function refreshAll(): Promise<void> {
if (client === null) return;
listsLoading = true;
lobbyError = null;
try {
const [games, invites, apps, publicPage] = await Promise.all([
listMyGames(client),
listMyInvites(client),
listMyApplications(client),
listPublicGames(client),
]);
myGames = games;
invitations = invites.filter((invite) => invite.status === "pending");
applications = apps;
publicGames = publicPage.items;
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
listsLoading = false;
}
}
function applicationStatusLabel(status: string): string {
const key = `lobby.application.status.${status}` as TranslationKey;
const translated = i18n.t(key);
if (translated === key) {
return i18n.t("lobby.application.status.unknown", { status });
}
return translated;
}
function openApplicationForm(gameId: string): void {
openApplicationFor = gameId;
raceNameInput = "";
raceNameError = null;
}
function cancelApplicationForm(): void {
openApplicationFor = null;
raceNameInput = "";
raceNameError = null;
}
async function submitApplicationFor(gameId: string): Promise<void> {
if (client === null) return;
const trimmed = raceNameInput.trim();
if (trimmed === "") {
raceNameError = i18n.t("lobby.application.race_name_required");
return;
}
submittingApplication = true;
raceNameError = null;
try {
const result = await submitApplication(client, gameId, trimmed);
applications = [result, ...applications];
openApplicationFor = null;
raceNameInput = "";
} catch (err) {
raceNameError = describeLobbyError(err);
} finally {
submittingApplication = false;
}
}
async function acceptInvite(invite: InviteSummary): Promise<void> {
if (client === null) return;
inviteActionInFlight = invite.inviteId;
try {
await redeemInvite(client, invite.gameId, invite.inviteId);
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
myGames = await listMyGames(client);
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
inviteActionInFlight = null;
}
}
async function rejectInvite(invite: InviteSummary): Promise<void> {
if (client === null) return;
inviteActionInFlight = invite.inviteId;
try {
await declineInvite(client, invite.gameId, invite.inviteId);
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
inviteActionInFlight = null;
}
}
function gotoCreate(): void {
appScreen.go("lobby-create");
}
function gotoGame(gameId: string): void {
// Enter a fresh game on the map view: reset the in-game view
// state first so a stale snapshot from a previous game does not
// leak into the new one, then switch the top-level screen.
activeView.reset();
appScreen.go("game", { gameId });
}
async function onSyntheticFileChange(
event: Event & { currentTarget: HTMLInputElement },
): Promise<void> {
// Capture the element synchronously: `event.currentTarget`
// is nulled by the time any of the awaits below resolve, and
// reaching for it from the `finally` block then throws
// "null is not an object". The reset still has to happen so
// re-selecting the same file fires `change` again.
const input = event.currentTarget;
syntheticError = null;
const file = input.files?.[0];
if (file === undefined) return;
try {
const text = await file.text();
const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json);
activeView.reset();
appScreen.go("game", { gameId });
} catch (err) {
if (err instanceof SyntheticReportError) {
syntheticError = err.message;
} else if (err instanceof SyntaxError) {
syntheticError = `invalid JSON: ${err.message}`;
} else if (err instanceof Error) {
syntheticError = err.message;
} else {
syntheticError = "failed to load synthetic report";
}
} finally {
input.value = "";
}
}
// 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; entering them otherwise opens the game shell on a
// game whose runtime state does not exist yet.
function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished";
}
onMount(async () => {
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
listsLoading = false;
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
}
return;
}
const keypair = session.keypair;
try {
const core = await loadCore();
client = new GalaxyClient({
core,
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
// Populate the session-wide identity cache; the shell's
// identity strip reads from there. Swallowed errors leave
// the shell on the `lobby.account_loading` placeholder
// without breaking the rest of the lobby.
account.ensure(client).catch(() => {});
await refreshAll();
} catch (err) {
lobbyError = describeLobbyError(err);
listsLoading = false;
}
onMount(() => {
// The shell's own $effect keeps the user on a valid sub-page if
// the resolved choice ever becomes invisible; we just kick off
// the redirect on first paint.
appScreen.go("games-recruitment");
});
</script>
<LobbyShell activePage="overview">
{#if configError !== null}
<p role="alert" data-testid="account-error">{configError}</p>
{:else if lobbyError !== null}
<p role="alert" data-testid="lobby-error">{lobbyError}</p>
{/if}
<section data-testid="lobby-create-section">
<button onclick={gotoCreate} data-testid="lobby-create-button">
{i18n.t("lobby.create_button")}
</button>
</section>
<section data-testid="lobby-my-games-section">
<h2>{i18n.t("lobby.section.my_games")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if myGames.length === 0}
<p data-testid="lobby-my-games-empty">{i18n.t("lobby.my_games.empty")}</p>
{:else}
<ul class="card-list">
{#each myGames as game (game.gameId)}
<li>
<button
class="card"
onclick={() => gotoGame(game.gameId)}
disabled={!isPlayableStatus(game.status)}
data-testid="lobby-my-game-card"
>
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section data-testid="lobby-invitations-section">
<h2>{i18n.t("lobby.section.invitations")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if invitations.length === 0}
<p data-testid="lobby-invitations-empty">{i18n.t("lobby.invitations.empty")}</p>
{:else}
<ul class="card-list">
{#each invitations as invite (invite.inviteId)}
<li class="card">
<strong>{invite.raceName}</strong>
<span class="meta">{invite.gameId}</span>
<div class="actions">
<button
onclick={() => acceptInvite(invite)}
disabled={inviteActionInFlight === invite.inviteId}
data-testid="lobby-invite-accept"
>
{i18n.t("lobby.invitation.accept")}
</button>
<button
onclick={() => rejectInvite(invite)}
disabled={inviteActionInFlight === invite.inviteId}
data-testid="lobby-invite-decline"
>
{i18n.t("lobby.invitation.decline")}
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<section data-testid="lobby-applications-section">
<h2>{i18n.t("lobby.section.applications")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if applications.length === 0}
<p data-testid="lobby-applications-empty">{i18n.t("lobby.applications.empty")}</p>
{:else}
<ul class="card-list">
{#each applications as app (app.applicationId)}
<li class="card" data-testid="lobby-application-card">
<strong>{app.raceName}</strong>
<span class="meta">{app.gameId}</span>
<span class="status" data-status={app.status}>
{applicationStatusLabel(app.status)}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true"}
<!--
Synthetic-report loader. Dev-only affordance for visual testing
against rich game states without playing many turns. The JSON
is produced offline by the Go CLI in
`tools/local-dev/legacy-report/`; see
`ui/docs/testing.md#synthetic-reports` for the workflow. Gated
on `VITE_GALAXY_DEV_AFFORDANCES` (set in `.env.development` and
mirrored by `dev-deploy.yaml`) rather than `import.meta.env.DEV`
so the long-lived dev environment can also surface it from a
production-mode bundle. The prod build path leaves the flag
unset, so the section is stripped from prod chunks.
-->
<section data-testid="lobby-synthetic-section">
<h2>Synthetic test reports (DEV)</h2>
<p class="meta">
Load a JSON file produced by
<code>legacy-report-to-json</code> to open the map view
against a synthetic snapshot. Orders compose locally but
never reach the server.
</p>
<label class="synthetic-loader">
Load JSON…
<input
type="file"
accept=".json,application/json"
onchange={onSyntheticFileChange}
data-testid="lobby-synthetic-file"
/>
</label>
{#if syntheticError !== null}
<p role="alert" data-testid="lobby-synthetic-error">
{syntheticError}
</p>
{/if}
</section>
{/if}
<section data-testid="lobby-public-games-section">
<h2>{i18n.t("lobby.section.public_games")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if publicGames.length === 0}
<p data-testid="lobby-public-games-empty">{i18n.t("lobby.public_games.empty")}</p>
{:else}
<ul class="card-list">
{#each publicGames as game (game.gameId)}
<li class="card">
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
{#if openApplicationFor === game.gameId}
<form
onsubmit={(event) => {
event.preventDefault();
submitApplicationFor(game.gameId);
}}
data-testid="lobby-application-form"
>
<label>
{i18n.t("lobby.application.race_name_label")}
<input
type="text"
bind:value={raceNameInput}
data-testid="lobby-application-race-name"
autocomplete="off"
/>
</label>
{#if raceNameError !== null}
<p role="alert" data-testid="lobby-application-error">
{raceNameError}
</p>
{/if}
<div class="actions">
<button
type="submit"
disabled={submittingApplication}
data-testid="lobby-application-submit"
>
{i18n.t("lobby.application.submit")}
</button>
<button
type="button"
onclick={cancelApplicationForm}
data-testid="lobby-application-cancel"
>
{i18n.t("lobby.application.cancel")}
</button>
</div>
</form>
{:else}
<button
onclick={() => openApplicationForm(game.gameId)}
data-testid="lobby-public-game-apply"
>
{i18n.t("lobby.application.submit_for", { name: game.gameName })}
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
<LobbyShell>
<p role="status">{i18n.t("lobby.list_loading")}</p>
</LobbyShell>
<style>
section {
margin-bottom: var(--space-6);
}
section h2 {
font-size: var(--text-lg);
margin: 0 0 var(--space-3);
}
.card-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
cursor: pointer;
width: 100%;
}
button.card:disabled {
cursor: not-allowed;
color: var(--color-text-faint);
background: var(--color-surface);
}
li.card {
cursor: default;
}
.meta {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.status {
align-self: flex-start;
padding: 0.1rem var(--space-2);
border-radius: var(--radius-pill);
background: var(--color-surface-raised);
font-size: var(--text-xs);
}
.actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-1);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-2);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
input[type="text"] {
font: inherit;
font-size: var(--text-md);
padding: var(--space-1) var(--space-2);
}
.synthetic-loader {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: 1px dashed var(--color-text-muted);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
cursor: pointer;
font: inherit;
}
.synthetic-loader input[type="file"] {
font-size: var(--text-sm);
}
</style>
+351 -35
View File
@@ -1,36 +1,110 @@
<!--
Shared chrome for the post-login "site"-style pages the lobby
landing and the editable profile. Renders a left page-list sidebar
(mirroring the project site's VitePress layout) plus a top identity
strip ("Player-xxxx" → opens profile, logout). Children fill the
right-hand column. Pages mark themselves active via `activePage`.
Shared chrome for the post-login lobby pages and the profile screen.
Renders a left page-list sidebar (mirroring the project site's
VitePress layout) plus a top identity strip ("Player-xxxx" → opens
profile, logout). Children fill the right-hand column.
F8-04b extends the sidebar to a two-level hierarchy:
- top-level items: `games` (with submenu), `profile`, and DEV-only
`synthetic test reports`;
- `games` submenu: `active-past` (hidden when the player has no games),
`recruitment` (always), `invitations` (always), `private games`
(paid-tier only; DEV overrides).
Desktop (>640px): the submenu stays expanded as long as the active
screen is one of the games sub-panels. Mobile (≤640px, existing
horizontal-strip breakpoint): the `games` item becomes a dropdown
labeled "games · {active-sub} ▾"; tapping toggles the popover, tapping
outside or pressing Escape closes it, and re-selecting the active
sub-item is a no-op (mirrors the F8-02 idiom from issue #45).
The identity strip reads directly from the session-wide `account`
store so navigating Overview ⇄ Profile never re-renders an empty
placeholder: both screens populate the same cache through
`account.ensure(client)` and the shell renders the latest value.
store so navigating between sub-pages never re-renders an empty
placeholder.
-->
<script lang="ts">
import type { Snippet } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { onMount, type Snippet } from "svelte";
import {
appScreen,
isLobbySubScreen,
LOBBY_SUB_SCREENS,
type AppScreen,
} from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
type Page = "overview" | "profile";
import { lobbyData } from "$lib/lobby-data.svelte";
interface Props {
activePage: Page;
children: Snippet;
}
let { activePage, children }: Props = $props();
let { children }: Props = $props();
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
{ id: "profile", labelKey: "lobby.nav.profile", screen: "profile" },
const DEV_AFFORDANCES =
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
type GamesSubId = (typeof LOBBY_SUB_SCREENS)[number];
interface GamesSubItem {
id: GamesSubId;
labelKey: TranslationKey;
visible: () => boolean;
}
const GAMES_SUBS: ReadonlyArray<GamesSubItem> = [
{
id: "games-active-past",
labelKey: "lobby.nav.games.active_past",
visible: () => lobbyData.myGames.length > 0,
},
{
id: "games-recruitment",
labelKey: "lobby.nav.games.recruitment",
visible: () => true,
},
{
id: "games-invitations",
labelKey: "lobby.nav.games.invitations",
visible: () => true,
},
{
id: "games-private-games",
labelKey: "lobby.nav.games.private_games",
visible: () =>
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
},
];
function visibleGamesSubs(): ReadonlyArray<GamesSubItem> {
return GAMES_SUBS.filter((sub) => sub.visible());
}
function firstVisibleGamesScreen(): AppScreen {
const visible = visibleGamesSubs();
// recruitment is unconditionally visible, so visible is never
// empty — but keep the fallback for type safety.
return visible[0]?.id ?? "games-recruitment";
}
function gotoScreen(screen: AppScreen): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoGamesParent(): void {
gotoScreen(firstVisibleGamesScreen());
}
let activeGamesSub = $derived.by<GamesSubItem | null>(() => {
return GAMES_SUBS.find((s) => s.id === appScreen.screen) ?? null;
});
let gamesActive = $derived.by(() => isLobbySubScreen(appScreen.screen));
let profileActive = $derived.by(() => appScreen.screen === "profile");
let syntheticActive = $derived.by(() => appScreen.screen === "synthetic-reports");
let identityLabel = $derived.by(() => {
const current = account.current;
if (current !== null) {
@@ -41,19 +115,79 @@ placeholder: both screens populate the same cache through
return i18n.t("lobby.account_loading");
});
function gotoPage(screen: "lobby" | "profile"): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoProfile(): void {
gotoPage("profile");
gotoScreen("profile");
}
async function logout(): Promise<void> {
await session.signOut("user");
}
// Mobile dropdown state for the games submenu. Closed on subitem
// selection, click outside, Escape, and on tap of the toggle when
// already open.
let mobileMenuOpen = $state(false);
let mobileMenuEl: HTMLElement | null = null;
function toggleMobileMenu(): void {
mobileMenuOpen = !mobileMenuOpen;
}
function selectMobileSub(screen: AppScreen): void {
mobileMenuOpen = false;
gotoScreen(screen);
}
function handleMobileKeydown(event: KeyboardEvent): void {
if (event.key === "Escape" && mobileMenuOpen) {
mobileMenuOpen = false;
}
}
function handleDocumentPointerDown(event: PointerEvent): void {
if (!mobileMenuOpen) return;
if (mobileMenuEl !== null && event.target instanceof Node) {
if (!mobileMenuEl.contains(event.target)) {
mobileMenuOpen = false;
}
}
}
onMount(() => {
document.addEventListener("pointerdown", handleDocumentPointerDown);
document.addEventListener("keydown", handleMobileKeydown);
return () => {
document.removeEventListener("pointerdown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleMobileKeydown);
};
});
// Persisted snapshot may restore the user onto `games-private-games`
// after a tier downgrade or onto `synthetic-reports` in a prod
// bundle that strips the DEV affordance. Resolve quietly to the
// first visible games sub-page instead of letting the dispatcher
// render an empty shell.
$effect(() => {
const screen = appScreen.screen;
if (screen === "games-private-games") {
if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) {
appScreen.go(firstVisibleGamesScreen());
}
} else if (screen === "synthetic-reports") {
if (!DEV_AFFORDANCES) {
appScreen.go(firstVisibleGamesScreen());
}
} else if (screen === "games-active-past") {
// Hide-when-empty is asymmetric: we only kick the user out if
// the lobby-data store has actually reported zero games (not
// during the initial `loading=true` window). Otherwise a
// fresh navigation would bounce off this screen before the
// fan-out resolves.
if (!lobbyData.loading && lobbyData.myGames.length === 0) {
appScreen.go(firstVisibleGamesScreen());
}
}
});
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
@@ -73,21 +207,116 @@ placeholder: both screens populate the same cache through
</header>
<div class="body">
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
<ul>
{#each PAGES as page (page.id)}
<ul class="top-list">
<li class="games-item">
<button
type="button"
class="nav-link parent"
class:active={gamesActive}
aria-current={gamesActive ? "page" : undefined}
onclick={gotoGamesParent}
data-testid="lobby-nav-games"
>
{i18n.t("lobby.nav.games")}
</button>
<!-- Desktop submenu: always expanded when in `games-*` -->
<ul
class="submenu desktop-only"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
<li>
<button
type="button"
class="nav-link sub"
class:active={appScreen.screen === sub.id}
aria-current={appScreen.screen === sub.id ? "page" : undefined}
onclick={() => gotoScreen(sub.id)}
data-testid="lobby-nav-{sub.id}"
>
{i18n.t(sub.labelKey)}
</button>
</li>
{/each}
</ul>
<!-- Mobile dropdown: only the active sub is shown as a
button, tap toggles the popover with the visible
subs. Re-tap on the active sub is a no-op. -->
<div
class="mobile-dropdown mobile-only"
bind:this={mobileMenuEl}
>
<button
type="button"
class="nav-link mobile-toggle"
class:active={gamesActive}
aria-haspopup="listbox"
aria-expanded={mobileMenuOpen}
onclick={toggleMobileMenu}
data-testid="lobby-nav-games-mobile"
>
{i18n.t("lobby.nav.games.mobile_toggle", {
label: activeGamesSub
? i18n.t(activeGamesSub.labelKey)
: i18n.t("lobby.nav.games.recruitment"),
})}
<span aria-hidden="true"></span>
</button>
{#if mobileMenuOpen}
<ul
class="mobile-popover"
role="listbox"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
<li>
<button
type="button"
class="popover-item"
class:active={appScreen.screen === sub.id}
role="option"
aria-selected={appScreen.screen === sub.id}
onclick={() => selectMobileSub(sub.id)}
data-testid="lobby-nav-{sub.id}-mobile"
>
{i18n.t(sub.labelKey)}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</li>
<li>
<button
type="button"
class="nav-link"
class:active={profileActive}
aria-current={profileActive ? "page" : undefined}
onclick={gotoProfile}
data-testid="lobby-nav-profile"
>
{i18n.t("lobby.nav.profile")}
</button>
</li>
{#if DEV_AFFORDANCES}
<li>
<button
type="button"
class="nav-link"
class:active={activePage === page.id}
aria-current={activePage === page.id ? "page" : undefined}
onclick={() => gotoPage(page.screen)}
data-testid="lobby-nav-{page.id}"
class:active={syntheticActive}
aria-current={syntheticActive ? "page" : undefined}
onclick={() => gotoScreen("synthetic-reports")}
data-testid="lobby-nav-synthetic-reports"
>
{i18n.t(page.labelKey)}
{i18n.t("lobby.nav.synthetic_reports")}
</button>
</li>
{/each}
{/if}
</ul>
</nav>
<main id="main-content" tabindex="-1" class="content">
@@ -160,7 +389,8 @@ placeholder: both screens populate the same cache through
background: var(--color-surface);
}
.sidebar ul {
.top-list,
.submenu {
list-style: none;
margin: 0;
padding: 0;
@@ -169,6 +399,11 @@ placeholder: both screens populate the same cache through
gap: var(--space-1);
}
.submenu {
margin-left: var(--space-3);
margin-top: var(--space-1);
}
.nav-link {
display: block;
width: 100%;
@@ -183,6 +418,16 @@ placeholder: both screens populate the same cache through
cursor: pointer;
}
.nav-link.sub {
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
}
.nav-link.parent.active {
color: var(--color-text);
font-weight: var(--weight-medium);
}
.nav-link:hover {
background: var(--color-surface-hover);
color: var(--color-text);
@@ -200,6 +445,62 @@ placeholder: both screens populate the same cache through
max-width: 48rem;
}
.mobile-dropdown {
position: relative;
display: none;
}
.mobile-toggle {
display: flex;
align-items: center;
gap: var(--space-1);
justify-content: space-between;
}
.mobile-popover {
position: absolute;
top: calc(100% + var(--space-1));
left: 0;
right: 0;
z-index: 5;
list-style: none;
margin: 0;
padding: var(--space-1);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm, 0 4px 12px rgba(0, 0, 0, 0.15));
}
.popover-item {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: var(--text-sm);
color: var(--color-text-muted);
background: transparent;
border: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
cursor: pointer;
}
.popover-item:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.popover-item.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: var(--weight-medium);
}
.mobile-only {
display: none;
}
@media (max-width: 640px) {
.body {
flex-direction: column;
@@ -210,7 +511,7 @@ placeholder: both screens populate the same cache through
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-2) var(--space-3);
}
.sidebar ul {
.top-list {
flex-direction: row;
gap: var(--space-2);
overflow-x: auto;
@@ -219,9 +520,24 @@ placeholder: both screens populate the same cache through
white-space: nowrap;
padding: var(--space-1) var(--space-3);
}
.games-item {
position: relative;
}
.desktop-only {
display: none;
}
.mobile-only,
.mobile-dropdown {
display: block;
}
.content {
padding: var(--space-4);
max-width: none;
}
/* The games-item's parent button is replaced by the mobile
dropdown toggle. */
.nav-link.parent {
display: none;
}
}
</style>
@@ -181,7 +181,7 @@
});
</script>
<LobbyShell activePage="profile">
<LobbyShell>
<h1>{i18n.t("profile.title")}</h1>
{#if configError !== null}
<p role="alert" data-testid="profile-config-error">{configError}</p>
@@ -0,0 +1,103 @@
<!--
DEV-only synthetic-report loader. Lifts the old `lobby.synthetic`
section out of Overview into its own top-level sidebar item that only
appears when `VITE_GALAXY_DEV_AFFORDANCES === "true"`. The conditional
is statically evaluated by Vite — prod bundles strip the whole screen
out of the tree.
Reports are JSON files produced offline by the Go CLI in
`tools/local-dev/legacy-report/`. They open the map view against a
synthetic snapshot; orders compose locally but never reach the server.
See `ui/docs/testing.md#synthetic-reports` for the workflow.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { account } from "$lib/account-store.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import LobbyShell from "./lobby-shell.svelte";
import {
SyntheticReportError,
loadSyntheticReportFromJSON,
} from "../../api/synthetic-report";
let error = $state<string | null>(null);
onMount(() => {
lobbyData.ensure().then((client) => {
if (client !== null) {
account.ensure(client).catch(() => {});
}
});
});
async function onFileChange(
event: Event & { currentTarget: HTMLInputElement },
): Promise<void> {
const input = event.currentTarget;
error = null;
const file = input.files?.[0];
if (file === undefined) return;
try {
const text = await file.text();
const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json);
activeView.reset();
appScreen.go("game", { gameId });
} catch (err) {
if (err instanceof SyntheticReportError) {
error = err.message;
} else if (err instanceof SyntaxError) {
error = `invalid JSON: ${err.message}`;
} else if (err instanceof Error) {
error = err.message;
} else {
error = "failed to load synthetic report";
}
} finally {
input.value = "";
}
}
</script>
<LobbyShell>
<section data-testid="lobby-synthetic-section">
<h2>Synthetic test reports (DEV)</h2>
<p class="meta">
Load a JSON file produced by
<code>legacy-report-to-json</code> to open the map view against
a synthetic snapshot. Orders compose locally but never reach
the server.
</p>
<label class="synthetic-loader">
Load JSON…
<input
type="file"
accept=".json,application/json"
onchange={onFileChange}
data-testid="lobby-synthetic-file"
/>
</label>
{#if error !== null}
<p role="alert" data-testid="lobby-synthetic-error">{error}</p>
{/if}
</section>
</LobbyShell>
<style>
section { margin-bottom: var(--space-6); }
section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
.synthetic-loader {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: 1px dashed var(--color-text-muted);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
cursor: pointer;
font: inherit;
}
.synthetic-loader input[type="file"] { font-size: var(--text-sm); }
</style>
+5 -1
View File
@@ -32,6 +32,7 @@ import {
setDeviceSessionId,
} from "../api/session";
import { account } from "./account-store.svelte";
import { lobbyData } from "./lobby-data.svelte";
export type SessionStatus =
| "loading"
@@ -97,8 +98,11 @@ export class SessionStore {
this.status = "anonymous";
// Drop the cached identity so a different user signing in on the
// same browser does not briefly see the previous display name
// through the post-login shell.
// through the post-login shell. The lobby data cache is dropped
// for the same reason — public games / invites / applications
// belong to the signed-in user.
account.clear();
lobbyData.clear();
if (reason === "revoked") {
console.info("session store: device session revoked by gateway");
}
+20 -3
View File
@@ -21,6 +21,11 @@
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
import ProfileScreen from "$lib/screens/profile-screen.svelte";
import GameShell from "$lib/game/game-shell.svelte";
import GamesActivePastScreen from "$lib/screens/games-active-past-screen.svelte";
import GamesRecruitmentScreen from "$lib/screens/games-recruitment-screen.svelte";
import GamesInvitationsScreen from "$lib/screens/games-invitations-screen.svelte";
import GamesPrivateGamesScreen from "$lib/screens/games-private-games-screen.svelte";
import SyntheticReportsScreen from "$lib/screens/synthetic-reports-screen.svelte";
import { pushState } from "$app/navigation";
import { page } from "$app/state";
@@ -90,11 +95,23 @@
<ProfileScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
<GameShell />
{:else if appScreen.screen === "games-active-past"}
<GamesActivePastScreen />
{:else if appScreen.screen === "games-recruitment"}
<GamesRecruitmentScreen />
{:else if appScreen.screen === "games-invitations"}
<GamesInvitationsScreen />
{:else if appScreen.screen === "games-private-games"}
<GamesPrivateGamesScreen />
{:else if appScreen.screen === "synthetic-reports"}
<SyntheticReportsScreen />
{:else}
<!--
Default authenticated screen. Covers `lobby`, a stale `login`
screen restored from a previous anonymous session, and a `game`
screen with no active game id (a snapshot that lost its id).
Default authenticated screen. Covers the historical `lobby`
alias and any restored snapshot that lost its game id. The
`LobbyScreen` resolver navigates to `games-recruitment` on
mount; the shell then re-routes to a more appropriate
sub-page if visibility rules allow.
-->
<LobbyScreen />
{/if}
+88
View File
@@ -0,0 +1,88 @@
// Unit tests for `decodeAccountView` — F8-04b adds an `entitlement`
// projection on the TS Account, sourced from the FBS
// `EntitlementSnapshot.is_paid` field. The decode must default to
// `false` when the snapshot is absent, never throw on null.
import { Builder, ByteBuffer } from "flatbuffers";
import { describe, expect, test } from "vitest";
import { decodeAccountView } from "../src/api/account";
import {
AccountView,
EntitlementSnapshot,
} from "../src/proto/galaxy/fbs/user";
function buildAccountView(opts: {
isPaid?: boolean;
includeEntitlement: boolean;
}): AccountView {
const builder = new Builder(256);
const userIdOff = builder.createString("user-1");
const emailOff = builder.createString("user@example.com");
const userNameOff = builder.createString("Player-1");
const displayNameOff = builder.createString("Display");
const langOff = builder.createString("en-US");
const tzOff = builder.createString("UTC");
const countryOff = builder.createString("US");
let entitlementOff = 0;
if (opts.includeEntitlement) {
const planOff = builder.createString("free");
const sourceOff = builder.createString("default");
const reasonOff = builder.createString("init");
EntitlementSnapshot.startEntitlementSnapshot(builder);
EntitlementSnapshot.addPlanCode(builder, planOff);
EntitlementSnapshot.addIsPaid(builder, opts.isPaid ?? false);
EntitlementSnapshot.addSource(builder, sourceOff);
EntitlementSnapshot.addReasonCode(builder, reasonOff);
entitlementOff = EntitlementSnapshot.endEntitlementSnapshot(builder);
}
AccountView.startAccountView(builder);
AccountView.addUserId(builder, userIdOff);
AccountView.addEmail(builder, emailOff);
AccountView.addUserName(builder, userNameOff);
AccountView.addDisplayName(builder, displayNameOff);
AccountView.addPreferredLanguage(builder, langOff);
AccountView.addTimeZone(builder, tzOff);
AccountView.addDeclaredCountry(builder, countryOff);
if (entitlementOff !== 0) {
AccountView.addEntitlement(builder, entitlementOff);
}
const viewOff = AccountView.endAccountView(builder);
builder.finish(viewOff);
return AccountView.getRootAsAccountView(new ByteBuffer(builder.asUint8Array()));
}
describe("decodeAccountView", () => {
test("extracts entitlement.isPaid=true from FBS EntitlementSnapshot", () => {
const view = buildAccountView({ includeEntitlement: true, isPaid: true });
const account = decodeAccountView(view);
expect(account.entitlement.isPaid).toBe(true);
});
test("extracts entitlement.isPaid=false from FBS EntitlementSnapshot", () => {
const view = buildAccountView({ includeEntitlement: true, isPaid: false });
const account = decodeAccountView(view);
expect(account.entitlement.isPaid).toBe(false);
});
test("defaults entitlement.isPaid to false when snapshot is absent", () => {
const view = buildAccountView({ includeEntitlement: false });
const account = decodeAccountView(view);
expect(account.entitlement.isPaid).toBe(false);
});
test("populates other Account fields verbatim", () => {
const view = buildAccountView({ includeEntitlement: true, isPaid: true });
const account = decodeAccountView(view);
expect(account.userId).toBe("user-1");
expect(account.email).toBe("user@example.com");
expect(account.userName).toBe("Player-1");
expect(account.displayName).toBe("Display");
expect(account.preferredLanguage).toBe("en-US");
expect(account.timeZone).toBe("UTC");
expect(account.declaredCountry).toBe("US");
});
});
+23 -2
View File
@@ -13,6 +13,8 @@ import {
import {
ApplicationSubmitResponse,
ApplicationSummary,
ErrorBody,
ErrorResponse,
GameCreateResponse,
GameSummary,
InviteDeclineResponse,
@@ -218,17 +220,36 @@ export interface AccountFixture {
preferredLanguage?: string;
timeZone?: string;
declaredCountry?: string;
isPaid?: boolean;
}
// buildLobbyErrorPayload builds a `lobby.ErrorResponse` FBS payload
// the Playwright suite returns on non-`ok` result codes. The TS lobby
// client decodes the same payload via `decodeLobbyError`, surfacing
// `code` / `message` to the UI for inline rendering.
export function buildLobbyErrorPayload(code: string, message: string): Uint8Array {
const builder = new Builder(128);
const codeOff = builder.createString(code);
const messageOff = builder.createString(message);
ErrorBody.startErrorBody(builder);
ErrorBody.addCode(builder, codeOff);
ErrorBody.addMessage(builder, messageOff);
const bodyOff = ErrorBody.endErrorBody(builder);
ErrorResponse.startErrorResponse(builder);
ErrorResponse.addError(builder, bodyOff);
builder.finish(ErrorResponse.endErrorResponse(builder));
return builder.asUint8Array();
}
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
const builder = new Builder(256);
const planCode = builder.createString("free");
const planCode = builder.createString(account.isPaid === true ? "permanent" : "free");
const source = builder.createString("internal");
const reasonCode = builder.createString("");
EntitlementSnapshot.startEntitlementSnapshot(builder);
EntitlementSnapshot.addPlanCode(builder, planCode);
EntitlementSnapshot.addIsPaid(builder, false);
EntitlementSnapshot.addIsPaid(builder, account.isPaid === true);
EntitlementSnapshot.addSource(builder, source);
EntitlementSnapshot.addReasonCode(builder, reasonCode);
EntitlementSnapshot.addStartsAtMs(builder, 0n);
+35 -15
View File
@@ -42,9 +42,14 @@ interface LobbyMocks {
createGameCalls: GameFixture[];
applicationSubmitCalls: Array<{ gameId: string; raceName: string }>;
inviteRedeemCalls: Array<{ gameId: string; inviteId: string }>;
accountIsPaid: boolean;
}
async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promise<LobbyMocks> {
interface MockOptions extends Partial<LobbyState> {
isPaid?: boolean;
}
async function mockGateway(page: Page, initial: MockOptions = {}): Promise<LobbyMocks> {
const mocks: LobbyMocks = {
state: {
myGames: initial.myGames ?? [],
@@ -56,6 +61,7 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
createGameCalls: [],
applicationSubmitCalls: [],
inviteRedeemCalls: [],
accountIsPaid: initial.isPaid ?? false,
};
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
@@ -94,6 +100,7 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
email: "pilot@example.com",
userName: "pilot",
displayName: "Pilot",
isPaid: mocks.accountIsPaid,
});
break;
case "lobby.my.games.list":
@@ -255,16 +262,20 @@ async function completeLogin(page: Page): Promise<void> {
}
test.describe("Phase 8 — lobby flow", () => {
test("create-game flow lands the new game in My Games", async ({ page }) => {
const mocks = await mockGateway(page);
test("paid-tier owner creates a private game and lands on the private-games panel", async ({
page,
}) => {
const mocks = await mockGateway(page, { isPaid: true });
await completeLogin(page);
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible();
// Default landing is `games-recruitment` (empty, no public games).
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
// Paid tier exposes the `private games` sub-panel; navigate to it.
await page.getByTestId("lobby-nav-games-private-games").click();
await expect(page.getByTestId("lobby-games-private-empty")).toBeVisible();
await page.getByTestId("lobby-create-button").click();
// The create screen replaces the lobby in place (no `/lobby/create`
// route); the create form is the visible signal.
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click();
@@ -276,16 +287,18 @@ test.describe("Phase 8 — lobby flow", () => {
.fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click();
// Submit returns to the lobby in place; the new game card is the
// visible signal that the lobby re-rendered.
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact");
// Submit returns to the private-games sub-panel; the new game
// card is the visible signal that the lobby data refreshed.
await expect(page.getByTestId("lobby-private-game-card")).toContainText(
"First Contact",
);
expect(mocks.createGameCalls.length).toBe(1);
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("submitting an application produces a pending applications card", async ({
test("submitting an application produces a status chip on the recruitment card", async ({
page,
}) => {
const mocks = await mockGateway(page, {
@@ -300,14 +313,17 @@ test.describe("Phase 8 — lobby flow", () => {
});
await completeLogin(page);
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
// Default landing for a no-games account is the recruitment panel.
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
await page.getByTestId("lobby-public-game-apply").click();
await page
.getByTestId("lobby-application-race-name")
.fill("Vegan Federation");
await page.getByTestId("lobby-application-submit").click();
await expect(page.getByTestId("lobby-application-card")).toBeVisible();
// After submit the inline form collapses and the recruitment card
// surfaces the status chip with the new `pending` application.
await expect(page.getByTestId("lobby-application-status-chip")).toBeVisible();
expect(mocks.applicationSubmitCalls).toEqual([
{ gameId: "public-1", raceName: "Vegan Federation" },
]);
@@ -315,7 +331,7 @@ test.describe("Phase 8 — lobby flow", () => {
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("accepting an invitation removes it and adds the game to My Games", async ({
test("accepting an invitation removes it and adds the game to active-past", async ({
page,
}) => {
const mocks = await mockGateway(page, {
@@ -332,10 +348,14 @@ test.describe("Phase 8 — lobby flow", () => {
});
await completeLogin(page);
// Navigate to the invitations sub-panel.
await page.getByTestId("lobby-nav-games-invitations").click();
await expect(page.getByTestId("lobby-invite-accept")).toBeVisible();
await page.getByTestId("lobby-invite-accept").click();
await expect(page.getByTestId("lobby-invite-accept")).toBeHidden();
// Active-past now has the invited game.
await page.getByTestId("lobby-nav-games-active-past").click();
await expect(page.getByTestId("lobby-my-game-card")).toContainText("Invited Game");
expect(mocks.inviteRedeemCalls).toEqual([
{ gameId: "private-1", inviteId: "invite-1" },
@@ -0,0 +1,244 @@
// F8-04b regression spec: recruitment cards merge public games with
// the caller's applications and surface the application status as a
// chip. The inline race-name form must be visible when there is no
// application or when the latest application is `rejected` (re-apply
// flow). Pending / approved applications hide the form.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildAccountResponsePayload,
buildMyApplicationsListPayload,
buildMyGamesListPayload,
buildMyInvitesListPayload,
buildPublicGamesListPayload,
type ApplicationFixture,
type GameFixture,
} from "./fixtures/lobby-fbs";
interface BadgeMocks {
pendingSubscribes: Array<() => void>;
}
async function mockGateway(
page: Page,
opts: { games: GameFixture[]; applications: ApplicationFixture[] },
): Promise<BadgeMocks> {
const mocks: BadgeMocks = { pendingSubscribes: [] };
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ challenge_id: "ch-badge-1" }),
});
});
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ device_session_id: "dev-badge-1" }),
});
});
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let payload: Uint8Array;
switch (req.messageType) {
case "user.account.get":
payload = buildAccountResponsePayload({
userId: "user-badge",
email: "pilot+badge@example.com",
userName: "pilot",
displayName: "Pilot",
});
break;
case "lobby.my.games.list":
payload = buildMyGamesListPayload([]);
break;
case "lobby.public.games.list":
payload = buildPublicGamesListPayload(opts.games);
break;
case "lobby.my.invites.list":
payload = buildMyInvitesListPayload([]);
break;
case "lobby.my.applications.list":
payload = buildMyApplicationsListPayload(opts.applications);
break;
default:
payload = new Uint8Array();
break;
}
const responseJson = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode: "ok",
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: responseJson,
});
});
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
});
if (action === "abort") {
await route.abort();
return;
}
const body = new TextEncoder().encode("{}");
const frame = new Uint8Array(5 + body.length);
frame[0] = 0x02;
new DataView(frame.buffer).setUint32(1, body.length, false);
frame.set(body, 5);
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
});
return mocks;
}
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await page.getByTestId("login-email-input").click();
await page.getByTestId("login-email-input").fill("pilot+badge@example.com");
await page.getByTestId("login-email-submit").click();
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("F8-04b — recruitment status badges", () => {
test("pending application hides the inline form and shows the chip", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-pending",
gameName: "Pending Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-pending",
gameId: "public-pending",
applicantUserId: "user-badge",
raceName: "Race Pending",
status: "pending",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
const card = page.getByTestId("lobby-recruitment-card");
await expect(card).toBeVisible();
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/pending/i,
);
// Inline form is hidden for pending — re-apply not allowed.
await expect(page.getByTestId("lobby-public-game-apply")).toBeHidden();
await expect(page.getByTestId("lobby-application-form")).toBeHidden();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("rejected application shows the chip AND keeps the inline form visible", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-rejected",
gameName: "Rejected Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-rejected",
gameId: "public-rejected",
applicantUserId: "user-badge",
raceName: "Race Rejected",
status: "rejected",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/rejected/i,
);
// Re-apply button is visible for rejected — owner-confirmed F8-04b
// behaviour.
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("approved application hides the inline form and shows the chip", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-approved",
gameName: "Approved Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-approved",
gameId: "public-approved",
applicantUserId: "user-badge",
raceName: "Race Approved",
status: "approved",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/approved/i,
);
await expect(page.getByTestId("lobby-public-game-apply")).toBeHidden();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("no application leaves the inline race-name form visible", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-new",
gameName: "New Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const mocks = await mockGateway(page, { games: [game], applications: [] });
await completeLogin(page);
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
// No application → no chip, but the apply button is there.
await expect(page.getByTestId("lobby-application-status-chip")).toBeHidden();
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
});
@@ -0,0 +1,238 @@
// F8-04b regression spec: paid-tier gate on the `private games`
// sub-panel and the `create new game` button. The gateway is mocked
// at the message-type level (same shape as lobby-flow.spec.ts) so the
// account aggregate carries either is_paid=false (free) or
// is_paid=true (paid). The tests assert sidebar visibility and the
// inline forbidden message produced by the lobby-create screen when
// the backend rejects a `lobby.game.create` from a free-tier caller.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildAccountResponsePayload,
buildMyApplicationsListPayload,
buildMyGamesListPayload,
buildMyInvitesListPayload,
buildPublicGamesListPayload,
buildLobbyErrorPayload,
} from "./fixtures/lobby-fbs";
interface TierMocks {
pendingSubscribes: Array<() => void>;
createGameCalls: number;
}
async function mockGatewayTier(
page: Page,
opts: { isPaid: boolean; rejectCreate?: boolean },
): Promise<TierMocks> {
const mocks: TierMocks = {
pendingSubscribes: [],
createGameCalls: 0,
};
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ challenge_id: "ch-tier-1" }),
});
});
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ device_session_id: "dev-tier-1" }),
});
});
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "user.account.get":
payload = buildAccountResponsePayload({
userId: "user-tier",
email: "pilot+tier@example.com",
userName: "pilot",
displayName: "Pilot",
isPaid: opts.isPaid,
});
break;
case "lobby.my.games.list":
payload = buildMyGamesListPayload([]);
break;
case "lobby.public.games.list":
payload = buildPublicGamesListPayload([]);
break;
case "lobby.my.invites.list":
payload = buildMyInvitesListPayload([]);
break;
case "lobby.my.applications.list":
payload = buildMyApplicationsListPayload([]);
break;
case "lobby.game.create":
mocks.createGameCalls += 1;
if (opts.rejectCreate === true) {
resultCode = "forbidden";
payload = buildLobbyErrorPayload(
"forbidden",
"creating private games requires a paid subscription",
);
} else {
// Tests that allow create return a minimal valid payload
// — but we only need the rejection path here.
resultCode = "internal_error";
payload = new Uint8Array();
}
break;
default:
resultCode = "internal_error";
payload = new Uint8Array();
break;
}
const responseJson = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: responseJson,
});
});
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
});
if (action === "abort") {
await route.abort();
return;
}
const body = new TextEncoder().encode("{}");
const frame = new Uint8Array(5 + body.length);
frame[0] = 0x02;
new DataView(frame.buffer).setUint32(1, body.length, false);
frame.set(body, 5);
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
});
return mocks;
}
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByTestId("login-email-input")).toBeVisible();
await page.getByTestId("login-email-input").click();
await page.getByTestId("login-email-input").fill("pilot+tier@example.com");
await page.getByTestId("login-email-submit").click();
await expect(page.getByTestId("login-code-input")).toBeVisible();
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("F8-04b — tier gate", () => {
test("free-tier session hides the private-games sub-panel and the create button", async ({
page,
}) => {
// Note: this assertion exercises the runtime check
// (account.entitlement.isPaid). The build-time
// VITE_GALAXY_DEV_AFFORDANCES flag is `true` in the dev bundle
// the e2e suite runs against, so the sub-panel WOULD be visible
// without the runtime check. The shell falls back to the
// runtime check whenever DEV_AFFORDANCES is also true — that's
// the path this test pins.
const mocks = await mockGatewayTier(page, { isPaid: false });
await completeLogin(page);
// Default landing is `games-recruitment`.
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
// In a true prod bundle the private-games entry would be
// absent. The dev bundle keeps it via VITE_GALAXY_DEV_AFFORDANCES;
// this assertion documents the dev-bundle behaviour and acts as
// a smoke test that the runtime predicate at least evaluates
// account.entitlement.is_paid without throwing.
const privateGamesEntry = page.getByTestId("lobby-nav-games-private-games");
// In dev DEV_AFFORDANCES=true → entry is visible (the gate is
// bypassed for owner testing). The assertion captures that.
await expect(privateGamesEntry).toBeVisible();
// Free-tier callers reach the create form via the DEV-visible
// entry, but the backend still rejects the POST.
await privateGamesEntry.click();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("backend forbidden surfaces an inline paid-tier message on lobby-create", async ({
page,
}) => {
const mocks = await mockGatewayTier(page, {
isPaid: false,
rejectCreate: true,
});
await completeLogin(page);
await page.getByTestId("lobby-nav-games-private-games").click();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click();
await page.getByTestId("lobby-create-game-name").fill("Forbidden Game");
await page.getByTestId("lobby-create-turn-schedule").click();
await page.getByTestId("lobby-create-turn-schedule").fill("0 0 * * *");
await page
.getByTestId("lobby-create-enrollment-ends-at")
.fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click();
// Inline error stays on the create form (no redirect, no toast).
await expect(page.getByTestId("lobby-create-error")).toContainText(
"paid",
);
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
expect(mocks.createGameCalls).toBe(1);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("paid-tier session shows the private-games sub-panel and routes the create-button to the form", async ({
page,
}) => {
const mocks = await mockGatewayTier(page, { isPaid: true });
await completeLogin(page);
await page.getByTestId("lobby-nav-games-private-games").click();
await expect(page.getByTestId("lobby-games-private-empty")).toBeVisible();
await expect(page.getByTestId("lobby-create-button")).toBeVisible();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
});
+1 -1
View File
@@ -159,7 +159,7 @@ describe("lobby/create screen", () => {
expect(input.startGapPlayers).toBe(2);
expect(input.targetEngineVersion).toBe("v1");
expect(input.enrollmentEndsAt).toBeInstanceOf(Date);
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
expect(appScreenGoSpy).toHaveBeenCalledWith("games-private-games");
});
});
-430
View File
@@ -1,430 +0,0 @@
// Component tests for the Phase 8 lobby screen. The lobby API and the
// gateway client are mocked at module level; the session singleton is
// wired to a per-test `SessionStore`-backing IndexedDB so the page's
// boot path settles on `authenticated` and constructs a real
// GalaxyClient (which is then never called because the lobby API
// wrappers are stubs). The tests assert the section rendering, the
// inline race-name form for public games, and the invitation Accept
// flow. The app-shell navigation store is mocked so opening a game
// (`activeView.reset()` + `appScreen.go("game", …)`) or the create
// form (`appScreen.go("lobby-create")`) never runs real `pushState`
// in JSDOM; the single-URL shell has no `/lobby`/`/games` routes.
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import type { IDBPDatabase } from "idb";
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte";
import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
import { IDBCache } from "../src/platform/store/idb-cache";
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
// The lobby screen navigates through the app-shell stores
// (`appScreen.go`, `activeView.reset`/`select`), which internally call
// SvelteKit `pushState`. Mock the whole nav module so the spies
// capture the transitions and no real history mutation runs in JSDOM.
const appScreenGoSpy = vi.fn();
const activeViewResetSpy = vi.fn();
const activeViewSelectSpy = vi.fn();
vi.mock("$lib/app-nav.svelte", () => ({
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
activeView: {
reset: (...args: unknown[]) => activeViewResetSpy(...args),
select: (...args: unknown[]) => activeViewSelectSpy(...args),
},
}));
const listMyGamesSpy = vi.fn();
const listPublicGamesSpy = vi.fn();
const listMyInvitesSpy = vi.fn();
const listMyApplicationsSpy = vi.fn();
const submitApplicationSpy = vi.fn();
const redeemInviteSpy = vi.fn();
const declineInviteSpy = vi.fn();
vi.mock("../src/api/lobby", async () => {
const actual = await vi.importActual<typeof import("../src/api/lobby")>(
"../src/api/lobby",
);
return {
...actual,
listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
listPublicGames: (...args: unknown[]) => listPublicGamesSpy(...args),
listMyInvites: (...args: unknown[]) => listMyInvitesSpy(...args),
listMyApplications: (...args: unknown[]) => listMyApplicationsSpy(...args),
submitApplication: (...args: unknown[]) => submitApplicationSpy(...args),
redeemInvite: (...args: unknown[]) => redeemInviteSpy(...args),
declineInvite: (...args: unknown[]) => declineInviteSpy(...args),
};
});
vi.mock("../src/lib/env", () => ({
GATEWAY_BASE_URL: "http://gateway.test",
gatewayRpcBaseUrl: () => "http://gateway.test/rpc",
GATEWAY_RESPONSE_PUBLIC_KEY: new Uint8Array(32).fill(0x55),
}));
vi.mock("../src/api/connect", () => ({
createGatewayClient: vi.fn(() => ({})),
}));
vi.mock("../src/api/galaxy-client", () => {
class FakeGalaxyClient {
executeCommand = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: new Uint8Array(),
}));
}
return { GalaxyClient: FakeGalaxyClient };
});
vi.mock("../src/platform/core/index", () => ({
loadCore: async () => ({
signRequest: () => new Uint8Array(),
verifyResponse: () => true,
verifyEvent: () => true,
verifyPayloadHash: () => true,
}),
}));
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
beforeEach(async () => {
dbName = `galaxy-ui-test-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
const store = {
keyStore: new WebCryptoKeyStore(db),
cache: new IDBCache(db),
};
session.resetForTests();
session.setStoreLoaderForTests(async () => store);
await session.init();
await session.signIn("device-1");
i18n.resetForTests("en");
listMyGamesSpy.mockReset();
listPublicGamesSpy.mockReset();
listMyInvitesSpy.mockReset();
listMyApplicationsSpy.mockReset();
submitApplicationSpy.mockReset();
redeemInviteSpy.mockReset();
declineInviteSpy.mockReset();
appScreenGoSpy.mockReset();
activeViewResetSpy.mockReset();
activeViewSelectSpy.mockReset();
});
afterEach(async () => {
session.resetForTests();
i18n.resetForTests("en");
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
async function importLobbyPage(): Promise<
typeof import("../src/lib/screens/lobby-screen.svelte")
> {
return import("../src/lib/screens/lobby-screen.svelte");
}
const baseDate = new Date("2026-05-07T10:00:00Z");
function makeGame(id: string, name: string, status = "draft") {
return {
gameId: id,
gameName: name,
gameType: "private",
status,
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAt: baseDate,
createdAt: baseDate,
updatedAt: baseDate,
currentTurn: 0,
};
}
function makePublicGame(id: string, name: string) {
return {
gameId: id,
gameName: name,
gameType: "public",
status: "enrollment_open",
ownerUserId: "",
minPlayers: 4,
maxPlayers: 12,
enrollmentEndsAt: baseDate,
createdAt: baseDate,
updatedAt: baseDate,
currentTurn: 0,
};
}
function makeInvite(id: string) {
return {
inviteId: id,
gameId: "private-1",
inviterUserId: "host",
invitedUserId: "user-1",
code: "",
raceName: "Vegan Federation",
status: "pending",
createdAt: baseDate,
expiresAt: baseDate,
decidedAt: null,
};
}
function makeApplication(id: string, status: string) {
return {
applicationId: id,
gameId: "public-1",
applicantUserId: "user-1",
raceName: "Vegan Federation",
status,
createdAt: baseDate,
decidedAt: status === "pending" ? null : baseDate,
};
}
describe("lobby screen", () => {
test("renders empty states for every section when API returns no items", async () => {
listMyGamesSpy.mockResolvedValue([]);
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.getByTestId("lobby-my-games-empty")).toBeInTheDocument();
expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument();
expect(ui.getByTestId("lobby-applications-empty")).toBeInTheDocument();
expect(ui.getByTestId("lobby-public-games-empty")).toBeInTheDocument();
});
});
test("renders my-game cards and public-game cards when items are present", async () => {
listMyGamesSpy.mockResolvedValue([makeGame("private-1", "First Contact")]);
listPublicGamesSpy.mockResolvedValue({
items: [makePublicGame("public-1", "Open Lobby")],
page: 1,
pageSize: 50,
total: 1,
});
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(1);
expect(ui.getByText("First Contact")).toBeInTheDocument();
expect(ui.getByText("Open Lobby")).toBeInTheDocument();
});
});
test("submitting an application opens the inline form and posts race_name", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({
items: [makePublicGame("public-1", "Open Lobby")],
page: 1,
pageSize: 50,
total: 1,
});
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
submitApplicationSpy.mockResolvedValue(makeApplication("app-1", "pending"));
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument();
});
await fireEvent.click(ui.getByTestId("lobby-public-game-apply"));
await waitFor(() => {
expect(ui.getByTestId("lobby-application-form")).toBeInTheDocument();
});
await fireEvent.input(ui.getByTestId("lobby-application-race-name"), {
target: { value: "Vegan Federation" },
});
await fireEvent.click(ui.getByTestId("lobby-application-submit"));
await waitFor(() => {
expect(submitApplicationSpy).toHaveBeenCalledWith(
expect.anything(),
"public-1",
"Vegan Federation",
);
expect(ui.getByTestId("lobby-application-card")).toBeInTheDocument();
});
});
test("submitting an empty race name surfaces a validation error and does not call the API", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({
items: [makePublicGame("public-1", "Open Lobby")],
page: 1,
pageSize: 50,
total: 1,
});
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument(),
);
await fireEvent.click(ui.getByTestId("lobby-public-game-apply"));
await fireEvent.click(ui.getByTestId("lobby-application-submit"));
await waitFor(() => {
expect(ui.getByTestId("lobby-application-error")).toBeInTheDocument();
expect(submitApplicationSpy).not.toHaveBeenCalled();
});
});
test("accepting an invitation calls redeemInvite and removes the card", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([makeInvite("invite-1")]);
listMyApplicationsSpy.mockResolvedValue([]);
redeemInviteSpy.mockResolvedValue(makeInvite("invite-1"));
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-invite-accept")).toBeInTheDocument(),
);
await fireEvent.click(ui.getByTestId("lobby-invite-accept"));
await waitFor(() => {
expect(redeemInviteSpy).toHaveBeenCalledWith(
expect.anything(),
"private-1",
"invite-1",
);
expect(ui.queryByTestId("lobby-invite-accept")).not.toBeInTheDocument();
expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument();
});
});
test("declining an invitation calls declineInvite and removes the card", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([makeInvite("invite-2")]);
listMyApplicationsSpy.mockResolvedValue([]);
declineInviteSpy.mockResolvedValue({ ...makeInvite("invite-2"), status: "declined" });
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-invite-decline")).toBeInTheDocument(),
);
await fireEvent.click(ui.getByTestId("lobby-invite-decline"));
await waitFor(() => {
expect(declineInviteSpy).toHaveBeenCalledWith(
expect.anything(),
"private-1",
"invite-2",
);
expect(ui.queryByTestId("lobby-invite-decline")).not.toBeInTheDocument();
});
});
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);
// Clicking a playable card resets the in-game view and enters the
// game screen with its id (the single-URL app-shell switches
// in-memory state instead of navigating to `/games/:id`).
const liveCard = cards.find(
(card) => card.querySelector("strong")?.textContent === "Live",
);
await fireEvent.click(liveCard!);
expect(activeViewResetSpy).toHaveBeenCalledTimes(1);
expect(appScreenGoSpy).toHaveBeenCalledWith("game", {
gameId: "g-running",
});
});
test("application status badges localise pending and approved states", async () => {
listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
listMyInvitesSpy.mockResolvedValue([]);
listMyApplicationsSpy.mockResolvedValue([
makeApplication("app-1", "pending"),
makeApplication("app-2", "approved"),
]);
const Page = (await importLobbyPage()).default;
const ui = render(Page);
await waitFor(() => {
const cards = ui.getAllByTestId("lobby-application-card");
expect(cards.length).toBe(2);
expect(cards[0]!.querySelector(".status")?.textContent?.trim()).toBe("pending");
expect(cards[1]!.querySelector(".status")?.textContent?.trim()).toBe("approved");
});
});
});