feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Reshape the lobby UI from a single Overview into a two-level sidebar (games · profile · DEV synthetic-reports) with four games sub-panels (active-past · recruitment · invitations · private-games). Move the `create new game` button into the private-games panel, merge the applications section into recruitment cards as status chips, and add DEV-only synthetic-report loader as a top-level screen. Add a paid-tier gate at backend `lobby.game.create`: free callers get `403 forbidden` before the lobby service is invoked. The UI hides the private-games sub-panel + create button on free tier (DEV affordances flag overrides). Update every integration test that creates a game to use a new `testenv.PromoteToPaid` helper; add a new `TestLobbyFlow_FreeUserCreateGameForbidden`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "операция запрещена",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user