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,335 @@
|
||||
// Unit tests for the typed lobby.ts wrappers. They invoke the
|
||||
// wrappers against a minimal stub of `GalaxyClient.executeCommand`
|
||||
// that captures the message type and FlatBuffers request payload,
|
||||
// then returns a forged FlatBuffers response payload built with the
|
||||
// generated TS bindings. No network, no signing — the test confirms
|
||||
// the encoder/decoder shape matches the gateway contract and that
|
||||
// non-`ok` result codes are surfaced as a `LobbyError`.
|
||||
|
||||
import { Builder, ByteBuffer } from "flatbuffers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
LobbyError,
|
||||
createGame,
|
||||
declineInvite,
|
||||
listMyApplications,
|
||||
listMyGames,
|
||||
listMyInvites,
|
||||
listPublicGames,
|
||||
redeemInvite,
|
||||
submitApplication,
|
||||
} from "../src/api/lobby";
|
||||
import {
|
||||
ApplicationSubmitResponse,
|
||||
ApplicationSummary,
|
||||
ErrorBody,
|
||||
ErrorResponse,
|
||||
GameCreateResponse,
|
||||
GameSummary,
|
||||
InviteDeclineResponse,
|
||||
InviteRedeemResponse,
|
||||
InviteSummary,
|
||||
MyApplicationsListResponse,
|
||||
MyGamesListResponse,
|
||||
MyInvitesListResponse,
|
||||
PublicGamesListResponse,
|
||||
} from "../src/proto/galaxy/fbs/lobby";
|
||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
||||
import {
|
||||
GameCreateRequest,
|
||||
PublicGamesListRequest,
|
||||
ApplicationSubmitRequest,
|
||||
InviteRedeemRequest,
|
||||
InviteDeclineRequest,
|
||||
} from "../src/proto/galaxy/fbs/lobby";
|
||||
|
||||
interface Captured {
|
||||
messageType: string;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
function makeStub(
|
||||
respondWith: (c: Captured) => { resultCode?: string; payloadBytes: Uint8Array },
|
||||
): {
|
||||
client: GalaxyClient;
|
||||
captured: Captured[];
|
||||
} {
|
||||
const captured: Captured[] = [];
|
||||
const stub = {
|
||||
executeCommand: vi.fn(async (messageType: string, payload: Uint8Array) => {
|
||||
const c = { messageType, payload };
|
||||
captured.push(c);
|
||||
const result = respondWith(c);
|
||||
return {
|
||||
resultCode: result.resultCode ?? "ok",
|
||||
payloadBytes: result.payloadBytes,
|
||||
};
|
||||
}),
|
||||
} as unknown as GalaxyClient;
|
||||
return { client: stub, captured };
|
||||
}
|
||||
|
||||
function encodeGameSummary(builder: Builder): number {
|
||||
const gameId = builder.createString("g-1");
|
||||
const gameName = builder.createString("Test Game");
|
||||
const gameType = builder.createString("private");
|
||||
const status = builder.createString("draft");
|
||||
const ownerUserId = builder.createString("user-1");
|
||||
GameSummary.startGameSummary(builder);
|
||||
GameSummary.addGameId(builder, gameId);
|
||||
GameSummary.addGameName(builder, gameName);
|
||||
GameSummary.addGameType(builder, gameType);
|
||||
GameSummary.addStatus(builder, status);
|
||||
GameSummary.addOwnerUserId(builder, ownerUserId);
|
||||
GameSummary.addMinPlayers(builder, 2);
|
||||
GameSummary.addMaxPlayers(builder, 8);
|
||||
GameSummary.addEnrollmentEndsAtMs(builder, 1_780_000_000_000n);
|
||||
GameSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
|
||||
GameSummary.addUpdatedAtMs(builder, 1_770_000_000_000n);
|
||||
return GameSummary.endGameSummary(builder);
|
||||
}
|
||||
|
||||
function encodeApplicationSummary(builder: Builder, status: string): number {
|
||||
const applicationId = builder.createString("app-1");
|
||||
const gameId = builder.createString("g-1");
|
||||
const applicantUserId = builder.createString("user-1");
|
||||
const raceName = builder.createString("Vegan Federation");
|
||||
const statusOff = builder.createString(status);
|
||||
ApplicationSummary.startApplicationSummary(builder);
|
||||
ApplicationSummary.addApplicationId(builder, applicationId);
|
||||
ApplicationSummary.addGameId(builder, gameId);
|
||||
ApplicationSummary.addApplicantUserId(builder, applicantUserId);
|
||||
ApplicationSummary.addRaceName(builder, raceName);
|
||||
ApplicationSummary.addStatus(builder, statusOff);
|
||||
ApplicationSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
|
||||
ApplicationSummary.addDecidedAtMs(builder, status === "pending" ? 0n : 1_770_010_000_000n);
|
||||
return ApplicationSummary.endApplicationSummary(builder);
|
||||
}
|
||||
|
||||
function encodeInviteSummary(builder: Builder, status: string): number {
|
||||
const inviteId = builder.createString("invite-1");
|
||||
const gameId = builder.createString("g-1");
|
||||
const inviter = builder.createString("user-host");
|
||||
const invited = builder.createString("user-1");
|
||||
const code = builder.createString("");
|
||||
const race = builder.createString("Vegan Federation");
|
||||
const statusOff = builder.createString(status);
|
||||
InviteSummary.startInviteSummary(builder);
|
||||
InviteSummary.addInviteId(builder, inviteId);
|
||||
InviteSummary.addGameId(builder, gameId);
|
||||
InviteSummary.addInviterUserId(builder, inviter);
|
||||
InviteSummary.addInvitedUserId(builder, invited);
|
||||
InviteSummary.addCode(builder, code);
|
||||
InviteSummary.addRaceName(builder, race);
|
||||
InviteSummary.addStatus(builder, statusOff);
|
||||
InviteSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
|
||||
InviteSummary.addExpiresAtMs(builder, 1_780_000_000_000n);
|
||||
InviteSummary.addDecidedAtMs(builder, status === "pending" ? 0n : 1_770_010_000_000n);
|
||||
return InviteSummary.endInviteSummary(builder);
|
||||
}
|
||||
|
||||
describe("lobby.ts wrappers", () => {
|
||||
test("listMyGames decodes the response and reports the message type", async () => {
|
||||
const { client, captured } = makeStub(() => {
|
||||
const builder = new Builder(256);
|
||||
const item = encodeGameSummary(builder);
|
||||
const items = MyGamesListResponse.createItemsVector(builder, [item]);
|
||||
MyGamesListResponse.startMyGamesListResponse(builder);
|
||||
MyGamesListResponse.addItems(builder, items);
|
||||
builder.finish(MyGamesListResponse.endMyGamesListResponse(builder));
|
||||
return { payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
|
||||
const games = await listMyGames(client);
|
||||
expect(captured[0]!.messageType).toBe("lobby.my.games.list");
|
||||
expect(games.length).toBe(1);
|
||||
expect(games[0]!.gameId).toBe("g-1");
|
||||
expect(games[0]!.minPlayers).toBe(2);
|
||||
});
|
||||
|
||||
test("listPublicGames passes pagination and decodes pageSize/total", async () => {
|
||||
const { client, captured } = makeStub(() => {
|
||||
const builder = new Builder(256);
|
||||
const item = encodeGameSummary(builder);
|
||||
const items = PublicGamesListResponse.createItemsVector(builder, [item]);
|
||||
PublicGamesListResponse.startPublicGamesListResponse(builder);
|
||||
PublicGamesListResponse.addItems(builder, items);
|
||||
PublicGamesListResponse.addPage(builder, 2);
|
||||
PublicGamesListResponse.addPageSize(builder, 25);
|
||||
PublicGamesListResponse.addTotal(builder, 51);
|
||||
builder.finish(PublicGamesListResponse.endPublicGamesListResponse(builder));
|
||||
return { payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
|
||||
const page = await listPublicGames(client, { page: 2, pageSize: 25 });
|
||||
expect(captured[0]!.messageType).toBe("lobby.public.games.list");
|
||||
const decodedRequest = PublicGamesListRequest.getRootAsPublicGamesListRequest(
|
||||
new ByteBuffer(captured[0]!.payload),
|
||||
);
|
||||
expect(decodedRequest.page()).toBe(2);
|
||||
expect(decodedRequest.pageSize()).toBe(25);
|
||||
|
||||
expect(page.items.length).toBe(1);
|
||||
expect(page.page).toBe(2);
|
||||
expect(page.pageSize).toBe(25);
|
||||
expect(page.total).toBe(51);
|
||||
});
|
||||
|
||||
test("listMyApplications decodes pending and decided records", async () => {
|
||||
const { client } = makeStub(() => {
|
||||
const builder = new Builder(256);
|
||||
const pending = encodeApplicationSummary(builder, "pending");
|
||||
const approved = encodeApplicationSummary(builder, "approved");
|
||||
const items = MyApplicationsListResponse.createItemsVector(builder, [pending, approved]);
|
||||
MyApplicationsListResponse.startMyApplicationsListResponse(builder);
|
||||
MyApplicationsListResponse.addItems(builder, items);
|
||||
builder.finish(MyApplicationsListResponse.endMyApplicationsListResponse(builder));
|
||||
return { payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
|
||||
const applications = await listMyApplications(client);
|
||||
expect(applications.length).toBe(2);
|
||||
expect(applications[0]!.status).toBe("pending");
|
||||
expect(applications[0]!.decidedAt).toBeNull();
|
||||
expect(applications[1]!.status).toBe("approved");
|
||||
expect(applications[1]!.decidedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
test("listMyInvites decodes user-bound invites", async () => {
|
||||
const { client } = makeStub(() => {
|
||||
const builder = new Builder(256);
|
||||
const invite = encodeInviteSummary(builder, "pending");
|
||||
const items = MyInvitesListResponse.createItemsVector(builder, [invite]);
|
||||
MyInvitesListResponse.startMyInvitesListResponse(builder);
|
||||
MyInvitesListResponse.addItems(builder, items);
|
||||
builder.finish(MyInvitesListResponse.endMyInvitesListResponse(builder));
|
||||
return { payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
|
||||
const invites = await listMyInvites(client);
|
||||
expect(invites.length).toBe(1);
|
||||
expect(invites[0]!.invitedUserId).toBe("user-1");
|
||||
expect(invites[0]!.status).toBe("pending");
|
||||
expect(invites[0]!.decidedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("createGame encodes every field and decodes the returned summary", async () => {
|
||||
const { client, captured } = makeStub(() => {
|
||||
const builder = new Builder(256);
|
||||
const game = encodeGameSummary(builder);
|
||||
GameCreateResponse.startGameCreateResponse(builder);
|
||||
GameCreateResponse.addGame(builder, game);
|
||||
builder.finish(GameCreateResponse.endGameCreateResponse(builder));
|
||||
return { payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
|
||||
const enrollment = new Date(1_780_000_000_000);
|
||||
const result = await createGame(client, {
|
||||
gameName: "First Contact",
|
||||
description: "",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 8,
|
||||
startGapHours: 24,
|
||||
startGapPlayers: 2,
|
||||
enrollmentEndsAt: enrollment,
|
||||
turnSchedule: "0 0 * * *",
|
||||
targetEngineVersion: "v1",
|
||||
});
|
||||
|
||||
expect(captured[0]!.messageType).toBe("lobby.game.create");
|
||||
const request = GameCreateRequest.getRootAsGameCreateRequest(
|
||||
new ByteBuffer(captured[0]!.payload),
|
||||
);
|
||||
expect(request.gameName()).toBe("First Contact");
|
||||
expect(request.turnSchedule()).toBe("0 0 * * *");
|
||||
expect(request.targetEngineVersion()).toBe("v1");
|
||||
expect(request.minPlayers()).toBe(2);
|
||||
expect(request.maxPlayers()).toBe(8);
|
||||
expect(request.enrollmentEndsAtMs()).toBe(BigInt(enrollment.getTime()));
|
||||
|
||||
expect(result.gameId).toBe("g-1");
|
||||
});
|
||||
|
||||
test("submitApplication encodes game_id and race_name", async () => {
|
||||
const { client, captured } = makeStub(() => {
|
||||
const builder = new Builder(128);
|
||||
const app = encodeApplicationSummary(builder, "pending");
|
||||
ApplicationSubmitResponse.startApplicationSubmitResponse(builder);
|
||||
ApplicationSubmitResponse.addApplication(builder, app);
|
||||
builder.finish(ApplicationSubmitResponse.endApplicationSubmitResponse(builder));
|
||||
return { payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
|
||||
const submitted = await submitApplication(client, "public-1", "Vegan Federation");
|
||||
expect(captured[0]!.messageType).toBe("lobby.application.submit");
|
||||
const decoded = ApplicationSubmitRequest.getRootAsApplicationSubmitRequest(
|
||||
new ByteBuffer(captured[0]!.payload),
|
||||
);
|
||||
expect(decoded.gameId()).toBe("public-1");
|
||||
expect(decoded.raceName()).toBe("Vegan Federation");
|
||||
expect(submitted.applicationId).toBe("app-1");
|
||||
});
|
||||
|
||||
test("redeemInvite and declineInvite hit their respective message types", async () => {
|
||||
const stubRedeem = makeStub(() => {
|
||||
const builder = new Builder(128);
|
||||
const invite = encodeInviteSummary(builder, "accepted");
|
||||
InviteRedeemResponse.startInviteRedeemResponse(builder);
|
||||
InviteRedeemResponse.addInvite(builder, invite);
|
||||
builder.finish(InviteRedeemResponse.endInviteRedeemResponse(builder));
|
||||
return { payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
const redeemed = await redeemInvite(stubRedeem.client, "private-1", "invite-1");
|
||||
expect(stubRedeem.captured[0]!.messageType).toBe("lobby.invite.redeem");
|
||||
const redeemReq = InviteRedeemRequest.getRootAsInviteRedeemRequest(
|
||||
new ByteBuffer(stubRedeem.captured[0]!.payload),
|
||||
);
|
||||
expect(redeemReq.gameId()).toBe("private-1");
|
||||
expect(redeemReq.inviteId()).toBe("invite-1");
|
||||
expect(redeemed.status).toBe("accepted");
|
||||
|
||||
const stubDecline = makeStub(() => {
|
||||
const builder = new Builder(128);
|
||||
const invite = encodeInviteSummary(builder, "declined");
|
||||
InviteDeclineResponse.startInviteDeclineResponse(builder);
|
||||
InviteDeclineResponse.addInvite(builder, invite);
|
||||
builder.finish(InviteDeclineResponse.endInviteDeclineResponse(builder));
|
||||
return { payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
const declined = await declineInvite(stubDecline.client, "private-1", "invite-1");
|
||||
expect(stubDecline.captured[0]!.messageType).toBe("lobby.invite.decline");
|
||||
const declineReq = InviteDeclineRequest.getRootAsInviteDeclineRequest(
|
||||
new ByteBuffer(stubDecline.captured[0]!.payload),
|
||||
);
|
||||
expect(declineReq.gameId()).toBe("private-1");
|
||||
expect(declineReq.inviteId()).toBe("invite-1");
|
||||
expect(declined.status).toBe("declined");
|
||||
});
|
||||
|
||||
test("non-ok result codes are surfaced as a LobbyError with code and message", async () => {
|
||||
const { client } = makeStub(() => {
|
||||
const builder = new Builder(128);
|
||||
const code = builder.createString("conflict");
|
||||
const message = builder.createString("game is not in enrollment_open");
|
||||
ErrorBody.startErrorBody(builder);
|
||||
ErrorBody.addCode(builder, code);
|
||||
ErrorBody.addMessage(builder, message);
|
||||
const errorOff = ErrorBody.endErrorBody(builder);
|
||||
ErrorResponse.startErrorResponse(builder);
|
||||
ErrorResponse.addError(builder, errorOff);
|
||||
builder.finish(ErrorResponse.endErrorResponse(builder));
|
||||
return { resultCode: "conflict", payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
|
||||
await expect(submitApplication(client, "public-1", "race")).rejects.toThrow(LobbyError);
|
||||
try {
|
||||
await submitApplication(client, "public-1", "race");
|
||||
} catch (err) {
|
||||
const lobbyError = err as LobbyError;
|
||||
expect(lobbyError.code).toBe("conflict");
|
||||
expect(lobbyError.message).toBe("game is not in enrollment_open");
|
||||
expect(lobbyError.resultCode).toBe("conflict");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user