ce7a66b3e6
Replaces the Phase 10 map stub with live planet rendering driven by `user.games.report`, and wires the header turn counter to the same data. Phase 11's frontend sits on a per-game `GameStateStore` that lives in `lib/game-state.svelte.ts`: the in-game shell layout instantiates one per game, exposes it through Svelte context, and disposes it on remount. The store discovers the game's current turn through `lobby.my.games.list`, fetches the matching report, and exposes a TS-friendly snapshot to the header turn counter, the map view, and the inspector / order / calculator tabs that later phases will plug onto the same instance. The pipeline forced one cross-stage decision: the user surface needs the current turn number to know which report to fetch, but `GameSummary` did not expose it. Phase 11 extends the lobby catalogue (FB schema, transcoder, Go model, backend gameSummaryWire, gateway decoders, openapi, TS bindings, api/lobby.ts) with `current_turn:int32`. The data was already tracked in backend's `RuntimeSnapshot.CurrentTurn`; surfacing it is a wire change only. Two alternatives were rejected: a brand-new `user.games.state` message (full wire-flow for one field) and hard-coding `turn=0` (works for the dev sandbox, which never advances past zero, but renders the initial state for any real game). The change crosses Phase 8's already-shipped catalogue per the project's "decisions baked back into the live plan" rule — existing tests and fixtures are updated in the same patch. The state binding lives in `map/state-binding.ts::reportToWorld`: one Point primitive per planet across all four kinds (local / other / uninhabited / unidentified) with distinct fill colours, fill alphas, and point radii so the user can tell them apart at a glance. The planet engine number is reused as the primitive id so a hit-test result resolves directly to a planet without an extra lookup table. Zero-planet reports yield a well-formed empty world; malformed dimensions fall back to 1×1 so a bad report cannot crash the renderer. The map view's mount effect creates the renderer once and skips re-mount on no-op refreshes (same turn, same wrap mode); a turn change or wrap-mode flip disposes and recreates it. The renderer's external API does not yet expose `setWorld`; Phase 24 / 34 will extract it once high-frequency updates land. The store installs a `visibilitychange` listener that calls `refresh()` when the tab regains focus. Wrap-mode preference uses `Cache` namespace `game-prefs`, key `<gameId>/wrap-mode`, default `torus`. Phase 11 reads through `store.wrapMode`; Phase 29 wires the toggle UI on top of `setWrapMode`. Tests: Vitest unit coverage for `reportToWorld` (every kind, ids, styling, empty / zero-dimension edges, priority order) and for the store lifecycle (init success, missing-membership error, forbidden-result error, `setTurn`, wrap-mode persistence across instances, `failBootstrap`). Playwright e2e mocks the gateway for `lobby.my.games.list` and `user.games.report` and asserts the live data path: turn counter shows the reported turn, `active-view-map` flips to `data-status="ready"`, and `data-planet-count` matches the fixture count. The zero-planet regression and the missing-membership error path are covered. Phase 11 status stays `pending` in `ui/PLAN.md` until the local-ci run lands green; flipping to `done` follows in the next commit per the per-stage CI gate in `CLAUDE.md`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
// 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, currentTurn: number = 0): 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);
|
|
GameSummary.addCurrentTurn(builder, currentTurn);
|
|
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, 5);
|
|
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);
|
|
expect(games[0]!.currentTurn).toBe(5);
|
|
});
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|