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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user