phase 8: lobby UI + cross-stack lobby command catalog + TS FlatBuffers
- Extend pkg/model/lobby and pkg/schema/fbs/lobby.fbs with public-games
list, my-applications/invites lists, game-create, application-submit,
invite-redeem/decline. Mirror the matching transcoder pairs and Go
fixture round-trip tests.
- Wire the seven new lobby message types through
gateway/internal/backendclient/{routes,lobby_commands}.go with
per-command REST helpers, JSON-tolerant decoding of backend wire
shapes, and httptest-based unit coverage for success / 4xx / 5xx /
503 across each command.
- Introduce TS-side FlatBuffers via the `flatbuffers` runtime dep, a
`make fbs-ts` target driving flatc, and the generated bindings under
ui/frontend/src/proto/galaxy/fbs. Phase 7's `user.account.get` decode
now uses these bindings as well, closing the JSON.parse vs
FlatBuffers gap that would have failed against a real local stack.
- Replace the placeholder lobby with five sections (my games, pending
invitations, my applications, public games, create new game) and the
/lobby/create form. Submit-application uses an inline race-name
form on the public-game card; create-game keeps name / description /
turn_schedule / enrollment_ends_at always visible and the rest under
an Advanced toggle with TS-side defaults.
- Update lobby/+page.svelte to throw LobbyError on non-ok result codes;
GalaxyClient.executeCommand now returns { resultCode, payloadBytes }.
- Vitest binding round-trips, lobby.ts wrapper unit tests, lobby-page
+ lobby-create component tests, Playwright lobby-flow.spec covering
create / submit / accept across all four projects. Phase 7 e2e was
migrated to the FlatBuffers fixtures and to click+fill against the
Safari-autofill readonly inputs.
- Mark Phase 8 done in ui/PLAN.md, mirror the wire-format note into
Phase 7, append the new lobby commands to gateway/README.md and
docs/ARCHITECTURE.md, add ui/docs/lobby.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,45 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import { createEdgeGatewayClient } 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 { ByteBuffer } from "flatbuffers";
|
||||
import { AccountResponse } from "../../proto/galaxy/fbs/user";
|
||||
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { Builder } from "flatbuffers";
|
||||
import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user";
|
||||
|
||||
let displayName: string | null = $state(null);
|
||||
let accountError: string | null = $state(null);
|
||||
let accountLoading = $state(true);
|
||||
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 client: GalaxyClient | null = null;
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await session.signOut("user");
|
||||
}
|
||||
|
||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
payload as BufferSource,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGreeting(c: GalaxyClient): Promise<void> {
|
||||
const builder = new Builder(32);
|
||||
GetMyAccountRequest.startGetMyAccountRequest(builder);
|
||||
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
|
||||
const result = await c.executeCommand("user.account.get", builder.asUint8Array());
|
||||
if (result.resultCode !== "ok") {
|
||||
return;
|
||||
}
|
||||
const response = AccountResponse.getRootAsAccountResponse(
|
||||
new ByteBuffer(result.payloadBytes),
|
||||
);
|
||||
const account = response.account();
|
||||
if (account === null) {
|
||||
return;
|
||||
}
|
||||
const display = account.displayName();
|
||||
const userName = account.userName();
|
||||
displayName = display && display.length > 0 ? display : userName;
|
||||
}
|
||||
|
||||
function gotoCreate(): void {
|
||||
goto("/lobby/create");
|
||||
}
|
||||
|
||||
function gotoGame(gameId: string): void {
|
||||
goto(`/games/${gameId}/map`);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (
|
||||
session.keypair === null ||
|
||||
session.deviceSessionId === null ||
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
||||
) {
|
||||
accountLoading = false;
|
||||
listsLoading = false;
|
||||
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
|
||||
accountError =
|
||||
"VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
|
||||
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const keypair = session.keypair;
|
||||
try {
|
||||
const core = await loadCore();
|
||||
const client = new GalaxyClient({
|
||||
client = new GalaxyClient({
|
||||
core,
|
||||
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
|
||||
signer: (canonical) => keypair.sign(canonical),
|
||||
@@ -47,52 +208,284 @@
|
||||
deviceSessionId: session.deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
const payload = await client.executeCommand(
|
||||
"user.account.get",
|
||||
new TextEncoder().encode("{}"),
|
||||
);
|
||||
const decoded = JSON.parse(new TextDecoder().decode(payload)) as {
|
||||
account?: { display_name?: string; user_name?: string };
|
||||
};
|
||||
displayName =
|
||||
decoded.account?.display_name ?? decoded.account?.user_name ?? null;
|
||||
loadGreeting(client).catch(() => {});
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
accountError = err instanceof Error ? err.message : "request failed";
|
||||
} finally {
|
||||
accountLoading = false;
|
||||
lobbyError = describeLobbyError(err);
|
||||
listsLoading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>{i18n.t("lobby.title")}</h1>
|
||||
<p>
|
||||
{i18n.t("lobby.device_session_id_label")}:
|
||||
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
|
||||
</p>
|
||||
{#if accountLoading}
|
||||
<p>{i18n.t("lobby.account_loading")}</p>
|
||||
{:else if displayName !== null}
|
||||
<p data-testid="account-greeting">
|
||||
{i18n.t("lobby.greeting", { name: displayName })}
|
||||
<header>
|
||||
<h1>{i18n.t("lobby.title")}</h1>
|
||||
<p>
|
||||
{i18n.t("lobby.device_session_id_label")}:
|
||||
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
|
||||
</p>
|
||||
{:else if accountError !== null}
|
||||
<p role="alert" data-testid="account-error">{accountError}</p>
|
||||
{#if displayName !== null}
|
||||
<p data-testid="account-greeting">
|
||||
{i18n.t("lobby.greeting", { name: displayName })}
|
||||
</p>
|
||||
{/if}
|
||||
<button onclick={logout} data-testid="lobby-logout">
|
||||
{i18n.t("lobby.logout")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#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}
|
||||
<button onclick={logout} data-testid="lobby-logout">
|
||||
{i18n.t("lobby.logout")}
|
||||
</button>
|
||||
|
||||
<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>{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)}
|
||||
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>{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>{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>
|
||||
|
||||
<section data-testid="lobby-public-games-section">
|
||||
<h2>{i18n.t("lobby.section.public_games")}</h2>
|
||||
{#if listsLoading}
|
||||
<p>{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>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 2rem;
|
||||
padding: 1.5rem 1rem;
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.4rem;
|
||||
background: #fafafa;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
li.card {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
align-self: flex-start;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #e7e7e7;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user