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:
Ilia Denisov
2026-05-07 18:05:08 +02:00
parent 5d2a3b79c5
commit f57a290432
90 changed files with 11862 additions and 112 deletions
+361
View File
@@ -0,0 +1,361 @@
// Typed wrappers around `GalaxyClient.executeCommand` for the Phase 8
// lobby command catalog. Each wrapper builds a FlatBuffers request
// payload via the generated TS bindings, calls `executeCommand`, then
// decodes the FlatBuffers response payload. Errors carrying a non-`ok`
// `result_code` are surfaced as a thrown `LobbyError` so callers can
// branch on canonical lobby error codes (`invalid_request`,
// `subject_not_found`, `forbidden`, `conflict`, `internal_error`).
import { Builder, ByteBuffer } from "flatbuffers";
import type { GalaxyClient } from "./galaxy-client";
import {
ApplicationSubmitRequest,
ApplicationSubmitResponse,
ApplicationSummary as FbsApplicationSummary,
ErrorResponse as FbsErrorResponse,
GameCreateRequest,
GameCreateResponse,
GameSummary as FbsGameSummary,
InviteDeclineRequest,
InviteDeclineResponse,
InviteRedeemRequest,
InviteRedeemResponse,
InviteSummary as FbsInviteSummary,
MyApplicationsListRequest,
MyApplicationsListResponse,
MyGamesListRequest,
MyGamesListResponse,
MyInvitesListRequest,
MyInvitesListResponse,
PublicGamesListRequest,
PublicGamesListResponse,
} from "../proto/galaxy/fbs/lobby";
export class LobbyError extends Error {
readonly code: string;
readonly resultCode: string;
constructor(resultCode: string, code: string, message: string) {
super(message);
this.name = "LobbyError";
this.resultCode = resultCode;
this.code = code;
}
}
export interface GameSummary {
gameId: string;
gameName: string;
gameType: string;
status: string;
ownerUserId: string;
minPlayers: number;
maxPlayers: number;
enrollmentEndsAt: Date;
createdAt: Date;
updatedAt: Date;
}
export interface PublicGamesPage {
items: GameSummary[];
page: number;
pageSize: number;
total: number;
}
export interface ApplicationSummary {
applicationId: string;
gameId: string;
applicantUserId: string;
raceName: string;
status: string;
createdAt: Date;
decidedAt: Date | null;
}
export interface InviteSummary {
inviteId: string;
gameId: string;
inviterUserId: string;
invitedUserId: string;
code: string;
raceName: string;
status: string;
createdAt: Date;
expiresAt: Date;
decidedAt: Date | null;
}
export interface CreateGameInput {
gameName: string;
description: string;
minPlayers: number;
maxPlayers: number;
startGapHours: number;
startGapPlayers: number;
enrollmentEndsAt: Date;
turnSchedule: string;
targetEngineVersion: string;
}
const RESULT_CODE_OK = "ok";
export async function listMyGames(client: GalaxyClient): Promise<GameSummary[]> {
const builder = new Builder(32);
MyGamesListRequest.startMyGamesListRequest(builder);
builder.finish(MyGamesListRequest.endMyGamesListRequest(builder));
const payload = await execute(client, "lobby.my.games.list", builder.asUint8Array());
const response = MyGamesListResponse.getRootAsMyGamesListResponse(new ByteBuffer(payload));
const out: GameSummary[] = [];
for (let i = 0; i < response.itemsLength(); i++) {
const item = response.items(i);
if (item) {
out.push(decodeGameSummary(item));
}
}
return out;
}
export async function listPublicGames(
client: GalaxyClient,
options: { page?: number; pageSize?: number } = {},
): Promise<PublicGamesPage> {
const page = options.page ?? 1;
const pageSize = options.pageSize ?? 50;
const builder = new Builder(32);
PublicGamesListRequest.startPublicGamesListRequest(builder);
PublicGamesListRequest.addPage(builder, page);
PublicGamesListRequest.addPageSize(builder, pageSize);
builder.finish(PublicGamesListRequest.endPublicGamesListRequest(builder));
const payload = await execute(client, "lobby.public.games.list", builder.asUint8Array());
const response = PublicGamesListResponse.getRootAsPublicGamesListResponse(
new ByteBuffer(payload),
);
const out: GameSummary[] = [];
for (let i = 0; i < response.itemsLength(); i++) {
const item = response.items(i);
if (item) {
out.push(decodeGameSummary(item));
}
}
return {
items: out,
page: response.page(),
pageSize: response.pageSize(),
total: response.total(),
};
}
export async function listMyApplications(
client: GalaxyClient,
): Promise<ApplicationSummary[]> {
const builder = new Builder(32);
MyApplicationsListRequest.startMyApplicationsListRequest(builder);
builder.finish(MyApplicationsListRequest.endMyApplicationsListRequest(builder));
const payload = await execute(client, "lobby.my.applications.list", builder.asUint8Array());
const response = MyApplicationsListResponse.getRootAsMyApplicationsListResponse(
new ByteBuffer(payload),
);
const out: ApplicationSummary[] = [];
for (let i = 0; i < response.itemsLength(); i++) {
const item = response.items(i);
if (item) {
out.push(decodeApplicationSummary(item));
}
}
return out;
}
export async function listMyInvites(client: GalaxyClient): Promise<InviteSummary[]> {
const builder = new Builder(32);
MyInvitesListRequest.startMyInvitesListRequest(builder);
builder.finish(MyInvitesListRequest.endMyInvitesListRequest(builder));
const payload = await execute(client, "lobby.my.invites.list", builder.asUint8Array());
const response = MyInvitesListResponse.getRootAsMyInvitesListResponse(new ByteBuffer(payload));
const out: InviteSummary[] = [];
for (let i = 0; i < response.itemsLength(); i++) {
const item = response.items(i);
if (item) {
out.push(decodeInviteSummary(item));
}
}
return out;
}
export async function createGame(
client: GalaxyClient,
input: CreateGameInput,
): Promise<GameSummary> {
const builder = new Builder(256);
const gameNameOff = builder.createString(input.gameName);
const descriptionOff = builder.createString(input.description);
const turnScheduleOff = builder.createString(input.turnSchedule);
const targetEngineVersionOff = builder.createString(input.targetEngineVersion);
GameCreateRequest.startGameCreateRequest(builder);
GameCreateRequest.addGameName(builder, gameNameOff);
GameCreateRequest.addDescription(builder, descriptionOff);
GameCreateRequest.addMinPlayers(builder, input.minPlayers);
GameCreateRequest.addMaxPlayers(builder, input.maxPlayers);
GameCreateRequest.addStartGapHours(builder, input.startGapHours);
GameCreateRequest.addStartGapPlayers(builder, input.startGapPlayers);
GameCreateRequest.addEnrollmentEndsAtMs(builder, BigInt(input.enrollmentEndsAt.getTime()));
GameCreateRequest.addTurnSchedule(builder, turnScheduleOff);
GameCreateRequest.addTargetEngineVersion(builder, targetEngineVersionOff);
builder.finish(GameCreateRequest.endGameCreateRequest(builder));
const payload = await execute(client, "lobby.game.create", builder.asUint8Array());
const response = GameCreateResponse.getRootAsGameCreateResponse(new ByteBuffer(payload));
const game = response.game();
if (game === null) {
throw new LobbyError("internal_error", "internal_error", "game missing in response");
}
return decodeGameSummary(game);
}
export async function submitApplication(
client: GalaxyClient,
gameId: string,
raceName: string,
): Promise<ApplicationSummary> {
const builder = new Builder(128);
const gameIdOff = builder.createString(gameId);
const raceNameOff = builder.createString(raceName);
ApplicationSubmitRequest.startApplicationSubmitRequest(builder);
ApplicationSubmitRequest.addGameId(builder, gameIdOff);
ApplicationSubmitRequest.addRaceName(builder, raceNameOff);
builder.finish(ApplicationSubmitRequest.endApplicationSubmitRequest(builder));
const payload = await execute(client, "lobby.application.submit", builder.asUint8Array());
const response = ApplicationSubmitResponse.getRootAsApplicationSubmitResponse(
new ByteBuffer(payload),
);
const application = response.application();
if (application === null) {
throw new LobbyError("internal_error", "internal_error", "application missing in response");
}
return decodeApplicationSummary(application);
}
export async function redeemInvite(
client: GalaxyClient,
gameId: string,
inviteId: string,
): Promise<InviteSummary> {
const builder = new Builder(128);
const gameIdOff = builder.createString(gameId);
const inviteIdOff = builder.createString(inviteId);
InviteRedeemRequest.startInviteRedeemRequest(builder);
InviteRedeemRequest.addGameId(builder, gameIdOff);
InviteRedeemRequest.addInviteId(builder, inviteIdOff);
builder.finish(InviteRedeemRequest.endInviteRedeemRequest(builder));
const payload = await execute(client, "lobby.invite.redeem", builder.asUint8Array());
const response = InviteRedeemResponse.getRootAsInviteRedeemResponse(new ByteBuffer(payload));
const invite = response.invite();
if (invite === null) {
throw new LobbyError("internal_error", "internal_error", "invite missing in response");
}
return decodeInviteSummary(invite);
}
export async function declineInvite(
client: GalaxyClient,
gameId: string,
inviteId: string,
): Promise<InviteSummary> {
const builder = new Builder(128);
const gameIdOff = builder.createString(gameId);
const inviteIdOff = builder.createString(inviteId);
InviteDeclineRequest.startInviteDeclineRequest(builder);
InviteDeclineRequest.addGameId(builder, gameIdOff);
InviteDeclineRequest.addInviteId(builder, inviteIdOff);
builder.finish(InviteDeclineRequest.endInviteDeclineRequest(builder));
const payload = await execute(client, "lobby.invite.decline", builder.asUint8Array());
const response = InviteDeclineResponse.getRootAsInviteDeclineResponse(
new ByteBuffer(payload),
);
const invite = response.invite();
if (invite === null) {
throw new LobbyError("internal_error", "internal_error", "invite missing in response");
}
return decodeInviteSummary(invite);
}
async function execute(
client: GalaxyClient,
messageType: string,
payloadBytes: Uint8Array,
): Promise<Uint8Array> {
const result = await client.executeCommand(messageType, payloadBytes);
if (result.resultCode !== RESULT_CODE_OK) {
throw decodeLobbyError(result.resultCode, result.payloadBytes);
}
return result.payloadBytes;
}
function decodeLobbyError(resultCode: string, payload: Uint8Array): LobbyError {
let code = resultCode;
let message = resultCode;
try {
const errorResponse = FbsErrorResponse.getRootAsErrorResponse(new ByteBuffer(payload));
const body = errorResponse.error();
if (body) {
code = body.code() ?? resultCode;
message = body.message() ?? resultCode;
}
} catch (_err) {
// fall through to use raw resultCode as both code and message
}
return new LobbyError(resultCode, code, message);
}
function decodeGameSummary(summary: FbsGameSummary): GameSummary {
return {
gameId: summary.gameId() ?? "",
gameName: summary.gameName() ?? "",
gameType: summary.gameType() ?? "",
status: summary.status() ?? "",
ownerUserId: summary.ownerUserId() ?? "",
minPlayers: summary.minPlayers(),
maxPlayers: summary.maxPlayers(),
enrollmentEndsAt: dateFromMs(summary.enrollmentEndsAtMs()),
createdAt: dateFromMs(summary.createdAtMs()),
updatedAt: dateFromMs(summary.updatedAtMs()),
};
}
function decodeApplicationSummary(app: FbsApplicationSummary): ApplicationSummary {
return {
applicationId: app.applicationId() ?? "",
gameId: app.gameId() ?? "",
applicantUserId: app.applicantUserId() ?? "",
raceName: app.raceName() ?? "",
status: app.status() ?? "",
createdAt: dateFromMs(app.createdAtMs()),
decidedAt: optionalDateFromMs(app.decidedAtMs()),
};
}
function decodeInviteSummary(invite: FbsInviteSummary): InviteSummary {
return {
inviteId: invite.inviteId() ?? "",
gameId: invite.gameId() ?? "",
inviterUserId: invite.inviterUserId() ?? "",
invitedUserId: invite.invitedUserId() ?? "",
code: invite.code() ?? "",
raceName: invite.raceName() ?? "",
status: invite.status() ?? "",
createdAt: dateFromMs(invite.createdAtMs()),
expiresAt: dateFromMs(invite.expiresAtMs()),
decidedAt: optionalDateFromMs(invite.decidedAtMs()),
};
}
function dateFromMs(ms: bigint): Date {
return new Date(Number(ms));
}
function optionalDateFromMs(ms: bigint): Date | null {
if (ms === 0n) {
return null;
}
return new Date(Number(ms));
}