feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run

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:
Ilia Denisov
2026-05-26 23:53:53 +02:00
parent 98d1fe6cae
commit 009ea560f9
44 changed files with 2486 additions and 1118 deletions
+23 -2
View File
@@ -13,6 +13,8 @@ import {
import {
ApplicationSubmitResponse,
ApplicationSummary,
ErrorBody,
ErrorResponse,
GameCreateResponse,
GameSummary,
InviteDeclineResponse,
@@ -218,17 +220,36 @@ export interface AccountFixture {
preferredLanguage?: string;
timeZone?: string;
declaredCountry?: string;
isPaid?: boolean;
}
// buildLobbyErrorPayload builds a `lobby.ErrorResponse` FBS payload
// the Playwright suite returns on non-`ok` result codes. The TS lobby
// client decodes the same payload via `decodeLobbyError`, surfacing
// `code` / `message` to the UI for inline rendering.
export function buildLobbyErrorPayload(code: string, message: string): Uint8Array {
const builder = new Builder(128);
const codeOff = builder.createString(code);
const messageOff = builder.createString(message);
ErrorBody.startErrorBody(builder);
ErrorBody.addCode(builder, codeOff);
ErrorBody.addMessage(builder, messageOff);
const bodyOff = ErrorBody.endErrorBody(builder);
ErrorResponse.startErrorResponse(builder);
ErrorResponse.addError(builder, bodyOff);
builder.finish(ErrorResponse.endErrorResponse(builder));
return builder.asUint8Array();
}
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
const builder = new Builder(256);
const planCode = builder.createString("free");
const planCode = builder.createString(account.isPaid === true ? "permanent" : "free");
const source = builder.createString("internal");
const reasonCode = builder.createString("");
EntitlementSnapshot.startEntitlementSnapshot(builder);
EntitlementSnapshot.addPlanCode(builder, planCode);
EntitlementSnapshot.addIsPaid(builder, false);
EntitlementSnapshot.addIsPaid(builder, account.isPaid === true);
EntitlementSnapshot.addSource(builder, source);
EntitlementSnapshot.addReasonCode(builder, reasonCode);
EntitlementSnapshot.addStartsAtMs(builder, 0n);
+35 -15
View File
@@ -42,9 +42,14 @@ interface LobbyMocks {
createGameCalls: GameFixture[];
applicationSubmitCalls: Array<{ gameId: string; raceName: string }>;
inviteRedeemCalls: Array<{ gameId: string; inviteId: string }>;
accountIsPaid: boolean;
}
async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promise<LobbyMocks> {
interface MockOptions extends Partial<LobbyState> {
isPaid?: boolean;
}
async function mockGateway(page: Page, initial: MockOptions = {}): Promise<LobbyMocks> {
const mocks: LobbyMocks = {
state: {
myGames: initial.myGames ?? [],
@@ -56,6 +61,7 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
createGameCalls: [],
applicationSubmitCalls: [],
inviteRedeemCalls: [],
accountIsPaid: initial.isPaid ?? false,
};
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
@@ -94,6 +100,7 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
email: "pilot@example.com",
userName: "pilot",
displayName: "Pilot",
isPaid: mocks.accountIsPaid,
});
break;
case "lobby.my.games.list":
@@ -255,16 +262,20 @@ async function completeLogin(page: Page): Promise<void> {
}
test.describe("Phase 8 — lobby flow", () => {
test("create-game flow lands the new game in My Games", async ({ page }) => {
const mocks = await mockGateway(page);
test("paid-tier owner creates a private game and lands on the private-games panel", async ({
page,
}) => {
const mocks = await mockGateway(page, { isPaid: true });
await completeLogin(page);
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible();
// Default landing is `games-recruitment` (empty, no public games).
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
// Paid tier exposes the `private games` sub-panel; navigate to it.
await page.getByTestId("lobby-nav-games-private-games").click();
await expect(page.getByTestId("lobby-games-private-empty")).toBeVisible();
await page.getByTestId("lobby-create-button").click();
// The create screen replaces the lobby in place (no `/lobby/create`
// route); the create form is the visible signal.
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click();
@@ -276,16 +287,18 @@ test.describe("Phase 8 — lobby flow", () => {
.fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click();
// Submit returns to the lobby in place; the new game card is the
// visible signal that the lobby re-rendered.
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact");
// Submit returns to the private-games sub-panel; the new game
// card is the visible signal that the lobby data refreshed.
await expect(page.getByTestId("lobby-private-game-card")).toContainText(
"First Contact",
);
expect(mocks.createGameCalls.length).toBe(1);
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("submitting an application produces a pending applications card", async ({
test("submitting an application produces a status chip on the recruitment card", async ({
page,
}) => {
const mocks = await mockGateway(page, {
@@ -300,14 +313,17 @@ test.describe("Phase 8 — lobby flow", () => {
});
await completeLogin(page);
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
// Default landing for a no-games account is the recruitment panel.
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
await page.getByTestId("lobby-public-game-apply").click();
await page
.getByTestId("lobby-application-race-name")
.fill("Vegan Federation");
await page.getByTestId("lobby-application-submit").click();
await expect(page.getByTestId("lobby-application-card")).toBeVisible();
// After submit the inline form collapses and the recruitment card
// surfaces the status chip with the new `pending` application.
await expect(page.getByTestId("lobby-application-status-chip")).toBeVisible();
expect(mocks.applicationSubmitCalls).toEqual([
{ gameId: "public-1", raceName: "Vegan Federation" },
]);
@@ -315,7 +331,7 @@ test.describe("Phase 8 — lobby flow", () => {
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("accepting an invitation removes it and adds the game to My Games", async ({
test("accepting an invitation removes it and adds the game to active-past", async ({
page,
}) => {
const mocks = await mockGateway(page, {
@@ -332,10 +348,14 @@ test.describe("Phase 8 — lobby flow", () => {
});
await completeLogin(page);
// Navigate to the invitations sub-panel.
await page.getByTestId("lobby-nav-games-invitations").click();
await expect(page.getByTestId("lobby-invite-accept")).toBeVisible();
await page.getByTestId("lobby-invite-accept").click();
await expect(page.getByTestId("lobby-invite-accept")).toBeHidden();
// Active-past now has the invited game.
await page.getByTestId("lobby-nav-games-active-past").click();
await expect(page.getByTestId("lobby-my-game-card")).toContainText("Invited Game");
expect(mocks.inviteRedeemCalls).toEqual([
{ gameId: "private-1", inviteId: "invite-1" },
@@ -0,0 +1,244 @@
// F8-04b regression spec: recruitment cards merge public games with
// the caller's applications and surface the application status as a
// chip. The inline race-name form must be visible when there is no
// application or when the latest application is `rejected` (re-apply
// flow). Pending / approved applications hide the form.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildAccountResponsePayload,
buildMyApplicationsListPayload,
buildMyGamesListPayload,
buildMyInvitesListPayload,
buildPublicGamesListPayload,
type ApplicationFixture,
type GameFixture,
} from "./fixtures/lobby-fbs";
interface BadgeMocks {
pendingSubscribes: Array<() => void>;
}
async function mockGateway(
page: Page,
opts: { games: GameFixture[]; applications: ApplicationFixture[] },
): Promise<BadgeMocks> {
const mocks: BadgeMocks = { pendingSubscribes: [] };
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ challenge_id: "ch-badge-1" }),
});
});
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ device_session_id: "dev-badge-1" }),
});
});
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let payload: Uint8Array;
switch (req.messageType) {
case "user.account.get":
payload = buildAccountResponsePayload({
userId: "user-badge",
email: "pilot+badge@example.com",
userName: "pilot",
displayName: "Pilot",
});
break;
case "lobby.my.games.list":
payload = buildMyGamesListPayload([]);
break;
case "lobby.public.games.list":
payload = buildPublicGamesListPayload(opts.games);
break;
case "lobby.my.invites.list":
payload = buildMyInvitesListPayload([]);
break;
case "lobby.my.applications.list":
payload = buildMyApplicationsListPayload(opts.applications);
break;
default:
payload = new Uint8Array();
break;
}
const responseJson = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode: "ok",
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: responseJson,
});
});
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
});
if (action === "abort") {
await route.abort();
return;
}
const body = new TextEncoder().encode("{}");
const frame = new Uint8Array(5 + body.length);
frame[0] = 0x02;
new DataView(frame.buffer).setUint32(1, body.length, false);
frame.set(body, 5);
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
});
return mocks;
}
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await page.getByTestId("login-email-input").click();
await page.getByTestId("login-email-input").fill("pilot+badge@example.com");
await page.getByTestId("login-email-submit").click();
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("F8-04b — recruitment status badges", () => {
test("pending application hides the inline form and shows the chip", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-pending",
gameName: "Pending Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-pending",
gameId: "public-pending",
applicantUserId: "user-badge",
raceName: "Race Pending",
status: "pending",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
const card = page.getByTestId("lobby-recruitment-card");
await expect(card).toBeVisible();
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/pending/i,
);
// Inline form is hidden for pending — re-apply not allowed.
await expect(page.getByTestId("lobby-public-game-apply")).toBeHidden();
await expect(page.getByTestId("lobby-application-form")).toBeHidden();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("rejected application shows the chip AND keeps the inline form visible", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-rejected",
gameName: "Rejected Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-rejected",
gameId: "public-rejected",
applicantUserId: "user-badge",
raceName: "Race Rejected",
status: "rejected",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/rejected/i,
);
// Re-apply button is visible for rejected — owner-confirmed F8-04b
// behaviour.
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("approved application hides the inline form and shows the chip", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-approved",
gameName: "Approved Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const app: ApplicationFixture = {
applicationId: "app-approved",
gameId: "public-approved",
applicantUserId: "user-badge",
raceName: "Race Approved",
status: "approved",
createdAtMs: 1n,
};
const mocks = await mockGateway(page, { games: [game], applications: [app] });
await completeLogin(page);
await expect(page.getByTestId("lobby-application-status-chip")).toContainText(
/approved/i,
);
await expect(page.getByTestId("lobby-public-game-apply")).toBeHidden();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("no application leaves the inline race-name form visible", async ({
page,
}) => {
const game: GameFixture = {
gameId: "public-new",
gameName: "New Game",
gameType: "public",
status: "enrollment_open",
ownerUserId: "other-owner",
};
const mocks = await mockGateway(page, { games: [game], applications: [] });
await completeLogin(page);
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
// No application → no chip, but the apply button is there.
await expect(page.getByTestId("lobby-application-status-chip")).toBeHidden();
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
});
@@ -0,0 +1,238 @@
// F8-04b regression spec: paid-tier gate on the `private games`
// sub-panel and the `create new game` button. The gateway is mocked
// at the message-type level (same shape as lobby-flow.spec.ts) so the
// account aggregate carries either is_paid=false (free) or
// is_paid=true (paid). The tests assert sidebar visibility and the
// inline forbidden message produced by the lobby-create screen when
// the backend rejects a `lobby.game.create` from a free-tier caller.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildAccountResponsePayload,
buildMyApplicationsListPayload,
buildMyGamesListPayload,
buildMyInvitesListPayload,
buildPublicGamesListPayload,
buildLobbyErrorPayload,
} from "./fixtures/lobby-fbs";
interface TierMocks {
pendingSubscribes: Array<() => void>;
createGameCalls: number;
}
async function mockGatewayTier(
page: Page,
opts: { isPaid: boolean; rejectCreate?: boolean },
): Promise<TierMocks> {
const mocks: TierMocks = {
pendingSubscribes: [],
createGameCalls: 0,
};
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ challenge_id: "ch-tier-1" }),
});
});
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ device_session_id: "dev-tier-1" }),
});
});
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "user.account.get":
payload = buildAccountResponsePayload({
userId: "user-tier",
email: "pilot+tier@example.com",
userName: "pilot",
displayName: "Pilot",
isPaid: opts.isPaid,
});
break;
case "lobby.my.games.list":
payload = buildMyGamesListPayload([]);
break;
case "lobby.public.games.list":
payload = buildPublicGamesListPayload([]);
break;
case "lobby.my.invites.list":
payload = buildMyInvitesListPayload([]);
break;
case "lobby.my.applications.list":
payload = buildMyApplicationsListPayload([]);
break;
case "lobby.game.create":
mocks.createGameCalls += 1;
if (opts.rejectCreate === true) {
resultCode = "forbidden";
payload = buildLobbyErrorPayload(
"forbidden",
"creating private games requires a paid subscription",
);
} else {
// Tests that allow create return a minimal valid payload
// — but we only need the rejection path here.
resultCode = "internal_error";
payload = new Uint8Array();
}
break;
default:
resultCode = "internal_error";
payload = new Uint8Array();
break;
}
const responseJson = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: responseJson,
});
});
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
});
if (action === "abort") {
await route.abort();
return;
}
const body = new TextEncoder().encode("{}");
const frame = new Uint8Array(5 + body.length);
frame[0] = 0x02;
new DataView(frame.buffer).setUint32(1, body.length, false);
frame.set(body, 5);
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
});
return mocks;
}
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByTestId("login-email-input")).toBeVisible();
await page.getByTestId("login-email-input").click();
await page.getByTestId("login-email-input").fill("pilot+tier@example.com");
await page.getByTestId("login-email-submit").click();
await expect(page.getByTestId("login-code-input")).toBeVisible();
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("F8-04b — tier gate", () => {
test("free-tier session hides the private-games sub-panel and the create button", async ({
page,
}) => {
// Note: this assertion exercises the runtime check
// (account.entitlement.isPaid). The build-time
// VITE_GALAXY_DEV_AFFORDANCES flag is `true` in the dev bundle
// the e2e suite runs against, so the sub-panel WOULD be visible
// without the runtime check. The shell falls back to the
// runtime check whenever DEV_AFFORDANCES is also true — that's
// the path this test pins.
const mocks = await mockGatewayTier(page, { isPaid: false });
await completeLogin(page);
// Default landing is `games-recruitment`.
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
// In a true prod bundle the private-games entry would be
// absent. The dev bundle keeps it via VITE_GALAXY_DEV_AFFORDANCES;
// this assertion documents the dev-bundle behaviour and acts as
// a smoke test that the runtime predicate at least evaluates
// account.entitlement.is_paid without throwing.
const privateGamesEntry = page.getByTestId("lobby-nav-games-private-games");
// In dev DEV_AFFORDANCES=true → entry is visible (the gate is
// bypassed for owner testing). The assertion captures that.
await expect(privateGamesEntry).toBeVisible();
// Free-tier callers reach the create form via the DEV-visible
// entry, but the backend still rejects the POST.
await privateGamesEntry.click();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("backend forbidden surfaces an inline paid-tier message on lobby-create", async ({
page,
}) => {
const mocks = await mockGatewayTier(page, {
isPaid: false,
rejectCreate: true,
});
await completeLogin(page);
await page.getByTestId("lobby-nav-games-private-games").click();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click();
await page.getByTestId("lobby-create-game-name").fill("Forbidden Game");
await page.getByTestId("lobby-create-turn-schedule").click();
await page.getByTestId("lobby-create-turn-schedule").fill("0 0 * * *");
await page
.getByTestId("lobby-create-enrollment-ends-at")
.fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click();
// Inline error stays on the create form (no redirect, no toast).
await expect(page.getByTestId("lobby-create-error")).toContainText(
"paid",
);
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
expect(mocks.createGameCalls).toBe(1);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("paid-tier session shows the private-games sub-panel and routes the create-button to the form", async ({
page,
}) => {
const mocks = await mockGatewayTier(page, { isPaid: true });
await completeLogin(page);
await page.getByTestId("lobby-nav-games-private-games").click();
await expect(page.getByTestId("lobby-games-private-empty")).toBeVisible();
await expect(page.getByTestId("lobby-create-button")).toBeVisible();
await page.getByTestId("lobby-create-button").click();
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
});