009ea560f9
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>
151 lines
5.1 KiB
TypeScript
151 lines
5.1 KiB
TypeScript
// Typed wrappers around `GalaxyClient.executeCommand` for the user-
|
|
// account command catalog. Each wrapper builds a FlatBuffers request
|
|
// payload via the generated TS bindings, calls `executeCommand`, then
|
|
// decodes the `AccountResponse` reply. Errors with a non-`ok`
|
|
// `result_code` surface as a thrown `AccountError` carrying the
|
|
// canonical backend code (`invalid_request`, `subject_not_found`,
|
|
// `forbidden`, `conflict`, `internal_error`).
|
|
|
|
import { Builder, ByteBuffer } from "flatbuffers";
|
|
|
|
import type { GalaxyClient } from "./galaxy-client";
|
|
import {
|
|
AccountResponse,
|
|
AccountView,
|
|
ErrorResponse as FbsErrorResponse,
|
|
GetMyAccountRequest,
|
|
UpdateMyProfileRequest,
|
|
UpdateMySettingsRequest,
|
|
} from "../proto/galaxy/fbs/user";
|
|
|
|
const RESULT_CODE_OK = "ok";
|
|
|
|
export class AccountError extends Error {
|
|
readonly code: string;
|
|
readonly resultCode: string;
|
|
|
|
constructor(resultCode: string, code: string, message: string) {
|
|
super(message);
|
|
this.name = "AccountError";
|
|
this.resultCode = resultCode;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export interface Account {
|
|
userId: string;
|
|
email: string;
|
|
userName: string;
|
|
displayName: string;
|
|
preferredLanguage: string;
|
|
timeZone: string;
|
|
declaredCountry: string;
|
|
entitlement: AccountEntitlement;
|
|
}
|
|
|
|
// AccountEntitlement is the narrow view of the FBS EntitlementSnapshot
|
|
// the UI currently consumes. `isPaid` gates lobby affordances tied to
|
|
// the paid tier (F8-04b: private-games subpage + create-game button).
|
|
// Other snapshot fields (plan code, expiry timestamps) are intentionally
|
|
// omitted until a feature needs them.
|
|
export interface AccountEntitlement {
|
|
isPaid: boolean;
|
|
}
|
|
|
|
export async function getMyAccount(client: GalaxyClient): Promise<Account> {
|
|
const builder = new Builder(32);
|
|
GetMyAccountRequest.startGetMyAccountRequest(builder);
|
|
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
|
|
const payload = await execute(client, "user.account.get", builder.asUint8Array());
|
|
return decodeAccountResponse(payload);
|
|
}
|
|
|
|
export async function updateMyProfile(
|
|
client: GalaxyClient,
|
|
displayName: string,
|
|
): Promise<Account> {
|
|
const builder = new Builder(128);
|
|
const displayNameOff = builder.createString(displayName);
|
|
UpdateMyProfileRequest.startUpdateMyProfileRequest(builder);
|
|
UpdateMyProfileRequest.addDisplayName(builder, displayNameOff);
|
|
builder.finish(UpdateMyProfileRequest.endUpdateMyProfileRequest(builder));
|
|
const payload = await execute(client, "user.profile.update", builder.asUint8Array());
|
|
return decodeAccountResponse(payload);
|
|
}
|
|
|
|
export async function updateMySettings(
|
|
client: GalaxyClient,
|
|
preferredLanguage: string,
|
|
timeZone: string,
|
|
): Promise<Account> {
|
|
const builder = new Builder(128);
|
|
const preferredLanguageOff = builder.createString(preferredLanguage);
|
|
const timeZoneOff = builder.createString(timeZone);
|
|
UpdateMySettingsRequest.startUpdateMySettingsRequest(builder);
|
|
UpdateMySettingsRequest.addPreferredLanguage(builder, preferredLanguageOff);
|
|
UpdateMySettingsRequest.addTimeZone(builder, timeZoneOff);
|
|
builder.finish(UpdateMySettingsRequest.endUpdateMySettingsRequest(builder));
|
|
const payload = await execute(client, "user.settings.update", builder.asUint8Array());
|
|
return decodeAccountResponse(payload);
|
|
}
|
|
|
|
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 decodeAccountError(result.resultCode, result.payloadBytes);
|
|
}
|
|
return result.payloadBytes;
|
|
}
|
|
|
|
function decodeAccountError(resultCode: string, payload: Uint8Array): AccountError {
|
|
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 with the raw result code
|
|
}
|
|
return new AccountError(resultCode, code, message);
|
|
}
|
|
|
|
function decodeAccountResponse(payload: Uint8Array): Account {
|
|
if (payload.length === 0) {
|
|
throw new AccountError("internal_error", "internal_error", "empty account response");
|
|
}
|
|
const response = AccountResponse.getRootAsAccountResponse(new ByteBuffer(payload));
|
|
const view = response.account();
|
|
if (view === null) {
|
|
throw new AccountError("internal_error", "internal_error", "account missing in response");
|
|
}
|
|
return decodeAccountView(view);
|
|
}
|
|
|
|
// Exported for unit tests that build a synthetic AccountView via the
|
|
// FBS bindings and assert the resulting Account shape. Runtime callers
|
|
// reach the same decode path through `getMyAccount` / `updateMyProfile`
|
|
// / `updateMySettings`.
|
|
export function decodeAccountView(view: AccountView): Account {
|
|
const entitlement = view.entitlement();
|
|
return {
|
|
userId: view.userId() ?? "",
|
|
email: view.email() ?? "",
|
|
userName: view.userName() ?? "",
|
|
displayName: view.displayName() ?? "",
|
|
preferredLanguage: view.preferredLanguage() ?? "",
|
|
timeZone: view.timeZone() ?? "",
|
|
declaredCountry: view.declaredCountry() ?? "",
|
|
entitlement: {
|
|
isPaid: entitlement?.isPaid() ?? false,
|
|
},
|
|
};
|
|
}
|