ui/phase-14: auto-sync order draft + always GET on boot + header headline

Replaces the manual Submit button with an auto-sync pipeline driven
by `OrderDraftStore`: every successful add / remove / move
coalesces a `submitOrder` call so the engine always mirrors the
local draft. Removing the last command sends an empty cmd[] PUT —
the engine, repo, and rest model now accept that as a valid
"player cleared their draft" state.

`hydrateFromServer` is now invoked unconditionally on game boot so
a fresh device picks up the player's stored order, and the local
cache is overwritten by the server's view (server is the source of
truth).

Header replaces the static "race ?" + turn counter with a single
headline string `<race> @ <game>, turn <n>`, sourced from the
engine's Report.race + the lobby's GameSummary.gameName + the live
turn number, with a `?` fallback while any piece is loading.

Tests:
- engine: empty PUT round-trips, repo round-trips empty Commands
- order-draft: auto-sync sends full draft on every mutation,
  rejected response surfaces error sync status, rapid mutations
  coalesce, server hydration overwrites cache
- order-tab: per-row status flips through the auto-sync lifecycle,
  remove → empty cmd[] PUT, rejected → retry button
- inspector overlay: applied + valid + submitting all participate
  in the optimistic projection
- header: live race / game / turn rendering with fall-back

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 13:34:10 +02:00
parent 68d8607eaa
commit 229c43beb5
26 changed files with 1144 additions and 728 deletions
+2 -2
View File
@@ -177,7 +177,7 @@ test("map view renders the reported turn and planet count from a live report", a
"data-status",
"ready",
);
await expect(page.getByTestId("turn-counter")).toContainText("turn 4");
await expect(page.getByTestId("game-shell-headline")).toContainText("turn 4");
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count",
"4",
@@ -207,7 +207,7 @@ test("zero-planet game renders the empty world without errors", async ({
"data-status",
"ready",
);
await expect(page.getByTestId("turn-counter")).toContainText("turn 0");
await expect(page.getByTestId("game-shell-headline")).toContainText("turn 0");
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count",
"0",
+3 -2
View File
@@ -35,8 +35,9 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
}) => {
await bootShell(page);
await expect(page.getByTestId("game-shell-header")).toBeVisible();
await expect(page.getByTestId("race-name")).toContainText("race ?");
await expect(page.getByTestId("turn-counter")).toContainText("turn");
await expect(page.getByTestId("game-shell-headline")).toContainText(
"turn",
);
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
});
+22 -18
View File
@@ -213,7 +213,7 @@ async function clickPlanetCentre(page: Page): Promise<void> {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
test("rename a seeded planet, submit, observe overlay + persist after reload", async ({
test("rename a seeded planet auto-syncs and the overlay survives reload", async ({
page,
}, testInfo) => {
test.skip(
@@ -241,32 +241,30 @@ test("rename a seeded planet, submit, observe overlay + persist after reload", a
await input.fill("New-Earth");
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
// Open the order tab and assert the row.
// Overlay applies immediately on `valid` — no Submit click is
// required because the auto-sync pipeline drives the network.
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
"New-Earth",
);
// Open the order tab and assert the row plus the synced status bar.
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"New-Earth",
);
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"valid",
);
await orderTool.getByTestId("order-submit").click();
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"applied",
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
expect(handle.submittedRenameName).toBe("New-Earth");
// Switch back to the inspector — overlay should reflect the new name.
await page.getByTestId("sidebar-tab-inspector").click();
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
"New-Earth",
);
// Reload: the order draft is persisted; on cache-miss boots the
// hydrate-from-server path takes over. Both round-trips re-apply
// the overlay so the player still sees the renamed planet.
// Reload: the layout always polls user.games.order.get on boot,
// so the overlay is rebuilt from the server's stored order even
// when the local cache was wiped.
await page.reload();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
@@ -303,11 +301,17 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await orderTool.getByTestId("order-submit").click();
// The auto-sync pipeline reaches the server immediately after
// the inline confirm; the rejected verdict surfaces through the
// per-row status badge and the sync bar.
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"rejected",
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"error",
);
await page.getByTestId("sidebar-tab-inspector").click();
// Overlay does not apply rejected commands — old name persists.
+59 -11
View File
@@ -1,10 +1,9 @@
// Component tests for the Phase 10 in-game shell header. The header
// composes the static `race ?` placeholder, the placeholder
// turn-counter (Phase 11 wires the live source), the view-menu, and
// the account-menu. The tests assert the placeholder copy, that
// every view-menu entry dispatches `goto` with the right URL, and
// that the Logout entry of the account-menu calls
// `session.signOut("user")`.
// Component tests for the in-game shell header. The header composes
// the headline strip (`<race> @ <game>, turn N`, falling back to `?`
// while the lobby / report calls are in flight), the view-menu, and
// the account-menu. The tests assert the headline copy, that every
// view-menu entry dispatches `goto` with the right URL, and that the
// Logout entry of the account-menu calls `session.signOut("user")`.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
@@ -20,6 +19,31 @@ import {
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte";
import Header from "../src/lib/header/header.svelte";
import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
} from "../src/lib/game-state.svelte";
function withGameState(opts: {
gameName?: string;
race?: string;
turn?: number;
} = {}): Map<unknown, unknown> {
const store = new GameStateStore();
store.gameName = opts.gameName ?? "";
if (opts.race !== undefined || opts.turn !== undefined) {
store.report = {
turn: opts.turn ?? 0,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 0,
planets: [],
race: opts.race ?? "",
};
store.status = "ready";
}
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
}
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
vi.mock("$app/navigation", () => ({
@@ -37,19 +61,43 @@ afterEach(() => {
});
describe("game-shell header", () => {
test("renders the static race / turn placeholders and toggles", () => {
test("renders fall-back placeholders before the lobby / report data lands", () => {
const onToggleSidebar = vi.fn();
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
context: withGameState(),
});
expect(ui.getByTestId("race-name")).toHaveTextContent("race ?");
expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch(
/turn\s+\?/,
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"? @ ?, turn ?",
);
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
});
test("renders the live race / game / turn from GameStateStore", () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
context: withGameState({
gameName: "Phase 14",
race: "Federation",
turn: 7,
}),
});
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"Federation @ Phase 14, turn 7",
);
});
test("partial data still falls back gracefully (race known, game unknown)", () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
context: withGameState({ race: "Federation", turn: 3 }),
});
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"Federation @ ?, turn 3",
);
});
test("clicking the sidebar toggle invokes the prop callback", async () => {
const onToggleSidebar = vi.fn();
const ui = render(Header, {
@@ -72,6 +72,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 1000,
planetCount: planets.length,
planets,
race: "",
};
}
@@ -0,0 +1,240 @@
// Test helpers that fabricate `GalaxyClient` stand-ins for the
// auto-sync pipeline. Two flavours:
//
// - `recordingClient` — captures every `submitOrder` call and lets
// the test assert on the order of in-flight payloads. The
// outcome (`ok` / `rejected`) is settable per call so tests can
// simulate retry loops.
// - `fakeFetchClient` — wires a synthetic `user.games.order.get`
// response so `OrderDraftStore.hydrateFromServer` exercises the
// decoder against a populated FBS envelope.
//
// Both helpers live under `tests/helpers/` so they can be reused
// across `order-draft.test.ts`, `inspector-overlay.test.ts`, and
// future Phase 14+ specs.
import { Builder } from "flatbuffers";
import type { GalaxyClient } from "../../src/api/galaxy-client";
import { uuidToHiLo } from "../../src/api/game-state";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../../src/proto/galaxy/fbs/order";
import type { OrderCommand } from "../../src/sync/order-types";
interface RecordedCall {
messageType: string;
commandIds: string[];
}
interface RecordingHandle {
client: GalaxyClient;
calls: RecordedCall[];
setOutcome(outcome: "ok" | "rejected"): void;
waitForCalls(n: number): Promise<void>;
waitForIdle(): Promise<void>;
}
/**
* recordingClient returns a fake GalaxyClient whose `executeCommand`
* decodes the in-flight UserGamesOrder, records the cmd_ids, and
* answers with a synthesised UserGamesOrderResponse where every
* cmdApplied is true (when outcome="ok") or false (when outcome=
* "rejected"). An optional `delayMs` simulates network latency so
* tests can exercise the coalescing path.
*/
export function recordingClient(
gameId: string,
initialOutcome: "ok" | "rejected",
options: { delayMs?: number } = {},
): RecordingHandle {
const calls: RecordedCall[] = [];
let outcome: "ok" | "rejected" = initialOutcome;
let inFlight = 0;
const waiters: (() => void)[] = [];
const client: GalaxyClient = {
async executeCommand(messageType: string, payload: Uint8Array) {
inFlight += 1;
try {
if (options.delayMs !== undefined) {
await new Promise<void>((resolve) =>
setTimeout(resolve, options.delayMs),
);
}
if (messageType === "user.games.order") {
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(payload),
);
const length = decoded.commandsLength();
const commandIds: string[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const id = item.cmdId();
if (id !== null) commandIds.push(id);
}
calls.push({ messageType, commandIds });
if (outcome === "ok") {
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
}
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
throw new Error(`unexpected messageType ${messageType}`);
} finally {
inFlight -= 1;
if (inFlight === 0) {
while (waiters.length > 0) {
const wake = waiters.shift();
wake?.();
}
}
}
},
} as unknown as GalaxyClient;
return {
client,
calls,
setOutcome(next: "ok" | "rejected") {
outcome = next;
},
async waitForCalls(n: number) {
while (calls.length < n) {
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
},
async waitForIdle() {
if (inFlight === 0) return;
await new Promise<void>((resolve) => waiters.push(resolve));
},
};
}
/**
* fakeFetchClient returns a GalaxyClient stand-in whose
* `executeCommand` answers a single hard-coded
* UserGamesOrderGetResponse — enough for `hydrateFromServer` to
* decode a realistic payload without standing up a full mock
* gateway.
*/
export function fakeFetchClient(
gameId: string,
commands: OrderCommand[],
updatedAt: number,
found = true,
): { client: GalaxyClient } {
const client: GalaxyClient = {
async executeCommand(messageType: string) {
if (messageType !== "user.games.order.get") {
throw new Error(`unexpected messageType ${messageType}`);
}
return {
resultCode: "ok",
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found),
};
},
} as unknown as GalaxyClient;
return { client };
}
function encodeApplied(
gameId: string,
cmdIds: string[],
applied: boolean,
): Uint8Array {
const builder = new Builder(256);
const itemOffsets = cmdIds.map((id) => {
const cmdIdOffset = builder.createString(id);
const nameOffset = builder.createString("ignored");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(0),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addCmdApplied(builder, applied);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrderResponse.createCommandsVector(
builder,
itemOffsets,
);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(Date.now()));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}
function encodeOrderGet(
gameId: string,
commands: OrderCommand[],
updatedAt: number,
found: boolean,
): Uint8Array {
const builder = new Builder(256);
let orderOffset = 0;
if (found) {
const itemOffsets = commands.map((cmd) => {
if (cmd.kind !== "planetRename") {
throw new Error(`unsupported command kind ${cmd.kind}`);
}
const cmdIdOffset = builder.createString(cmd.id);
const nameOffset = builder.createString(cmd.name);
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(cmd.planetNumber),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt));
UserGamesOrder.addCommands(builder, commandsVec);
orderOffset = UserGamesOrder.endUserGamesOrder(builder);
}
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, found);
if (orderOffset !== 0) {
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
}
const offset =
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}
+52 -120
View File
@@ -7,12 +7,10 @@
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { Builder } from "flatbuffers";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import InspectorTab from "../src/lib/sidebar/inspector-tab.svelte";
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
@@ -29,22 +27,10 @@ import {
RENDERED_REPORT_CONTEXT_KEY,
createRenderedReportSource,
} from "../src/lib/rendered-report.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
GalaxyClientHolder,
} from "../src/lib/galaxy-client-context.svelte";
import { i18n } from "../src/lib/i18n/index.svelte";
import { uuidToHiLo, type GameReport, type ReportPlanet } from "../src/api/game-state";
import type { GalaxyClient } from "../src/api/galaxy-client";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB } from "../src/platform/store/idb";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
let dbName: string;
@@ -93,6 +79,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 1000,
planetCount: planets.length,
planets,
race: "",
};
}
@@ -134,30 +121,15 @@ describe("inspector overlay reactivity", () => {
name: "New-Earth",
});
// `valid` does not participate in the overlay — the player
// has not submitted yet, the inspector still shows the
// server-side name.
// `valid` already participates in the overlay (auto-sync may
// not have fired yet, but the player's intent is committed).
expect(draft.statuses[cmdId]).toBe("valid");
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent("Earth");
draft.markSubmitting([cmdId]);
await waitFor(() => {
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
});
draft.applyResults({
results: new Map([[cmdId, "applied"] as const]),
updatedAt: 99,
});
await waitFor(() => {
expect(draft.statuses[cmdId]).toBe("applied");
});
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
// A simulated server refresh that returns the *un-renamed*
// snapshot must not erase the overlay (turn cutoff has not
// run yet, the engine still reports the old name).
@@ -173,13 +145,39 @@ describe("inspector overlay reactivity", () => {
draft.dispose();
});
test("submit through the order tab applies the overlay end-to-end", async () => {
test("auto-sync after add applies the overlay end-to-end", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const cache = new IDBCache(db);
const draft = new OrderDraftStore();
await draft.init({
cache,
gameId: "11111111-2222-3333-4444-555555555555",
await draft.init({ cache, gameId: GAME_ID });
draft.bindClient(handle.client);
const gameState = new GameStateStore();
gameState.gameId = GAME_ID;
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
gameState.status = "ready";
const selection = new SelectionStore();
selection.selectPlanet(7);
const renderedReport = createRenderedReportSource(gameState, draft);
const context = new Map<unknown, unknown>([
[GAME_STATE_CONTEXT_KEY, gameState],
[SELECTION_CONTEXT_KEY, selection],
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
]);
const inspector = render(InspectorTab, { context });
await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"Earth",
);
});
const cmdId = "00000000-0000-0000-0000-000000000abc";
await draft.add({
kind: "planetRename",
@@ -187,94 +185,28 @@ describe("inspector overlay reactivity", () => {
planetNumber: 7,
name: "New-Earth",
});
const gameState = new GameStateStore();
gameState.gameId = "11111111-2222-3333-4444-555555555555";
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
gameState.status = "ready";
// Stub refresh to return the *un-renamed* server snapshot —
// the engine has not applied the rename yet (turn cutoff
// pending). The overlay must still show the new name.
gameState.refresh = (async () => {
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
}) as unknown as typeof gameState.refresh;
const selection = new SelectionStore();
selection.selectPlanet(7);
const renderedReport = createRenderedReportSource(gameState, draft);
const responsePayload = (() => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString(cmdId);
const nameOffset = builder.createString("New-Earth");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(7),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addCmdApplied(builder, true);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, [
item,
]);
const [hi, lo] = uuidToHiLo("11111111-2222-3333-4444-555555555555");
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(99));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
})();
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: responsePayload,
}));
const clientHolder = new GalaxyClientHolder();
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
const context = new Map<unknown, unknown>([
[GAME_STATE_CONTEXT_KEY, gameState],
[SELECTION_CONTEXT_KEY, selection],
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
]);
const inspector = render(InspectorTab, { context });
const orderTab = render(OrderTab, { context });
// Pre-submit: the inspector still shows the un-renamed snapshot.
await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"Earth",
);
});
const submit = orderTab.getByTestId("order-submit");
expect(submit).not.toBeDisabled();
await fireEvent.click(submit);
await waitFor(() => {
expect(draft.statuses[cmdId]).toBe("applied");
});
expect(exec).toHaveBeenCalledTimes(1);
// Overlay applies on `valid` immediately — auto-sync hasn't
// landed yet but the player's intent is committed.
await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
});
await handle.waitForCalls(1);
await waitFor(() => {
expect(draft.statuses[cmdId]).toBe("applied");
});
expect(handle.calls).toHaveLength(1);
expect(handle.calls[0]!.commandIds).toEqual([cmdId]);
// Inspector still shows the new name after auto-sync.
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
draft.dispose();
});
});
const GAME_ID = "11111111-2222-3333-4444-555555555555";
+189 -125
View File
@@ -9,6 +9,7 @@
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { IDBPDatabase } from "idb";
@@ -176,32 +177,6 @@ describe("OrderDraftStore", () => {
reload.dispose();
});
test("absent cache row flips needsServerHydration flag", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
expect(store.needsServerHydration).toBe(true);
store.dispose();
});
test("explicitly empty cache row honours the user's empty draft", async () => {
const seeded = new OrderDraftStore();
await seeded.init({ cache, gameId: GAME_ID });
await seeded.add({
kind: "planetRename",
id: "00000000-0000-0000-0000-000000000001",
planetNumber: 7,
name: "Earth",
});
await seeded.remove("00000000-0000-0000-0000-000000000001");
seeded.dispose();
const reload = new OrderDraftStore();
await reload.init({ cache, gameId: GAME_ID });
expect(reload.needsServerHydration).toBe(false);
expect(reload.commands).toEqual([]);
reload.dispose();
});
test("planetRename validates locally and statuses reflect valid/invalid", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
@@ -222,111 +197,200 @@ describe("OrderDraftStore", () => {
store.dispose();
});
test("markSubmitting / applyResults flip the status map", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
store.markSubmitting(["id-1"]);
expect(store.statuses["id-1"]).toBe("submitting");
store.applyResults({
results: new Map([["id-1", "applied"] as const]),
updatedAt: 99,
});
expect(store.statuses["id-1"]).toBe("applied");
expect(store.updatedAt).toBe(99);
store.dispose();
});
test("markRejected switches submitting entries to rejected", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
store.markSubmitting(["id-1"]);
store.markRejected(["id-1"]);
expect(store.statuses["id-1"]).toBe("rejected");
store.dispose();
});
test("revertSubmittingToValid restores status after a thrown submit", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
store.markSubmitting(["id-1"]);
store.revertSubmittingToValid();
expect(store.statuses["id-1"]).toBe("valid");
store.dispose();
});
test("hydrateFromServer seeds the draft on a fresh cache", async () => {
const fakeClient = {
executeCommand: async () => {
const { Builder } = await import("flatbuffers");
const { UUID } = await import("../src/proto/galaxy/fbs/common");
const order = await import("../src/proto/galaxy/fbs/order");
const builder = new Builder(128);
const cmdId = builder.createString("hydr-1");
const name = builder.createString("Hydrated");
const inner = order.CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(7),
name,
);
order.CommandItem.startCommandItem(builder);
order.CommandItem.addCmdId(builder, cmdId);
order.CommandItem.addPayloadType(
builder,
order.CommandPayload.CommandPlanetRename,
);
order.CommandItem.addPayload(builder, inner);
const item = order.CommandItem.endCommandItem(builder);
const cmds = order.UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = (await import("../src/api/game-state")).uuidToHiLo(
GAME_ID,
);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
order.UserGamesOrder.startUserGamesOrder(builder);
order.UserGamesOrder.addGameId(builder, gameIdOffset);
order.UserGamesOrder.addUpdatedAt(builder, BigInt(7));
order.UserGamesOrder.addCommands(builder, cmds);
const orderOffset = order.UserGamesOrder.endUserGamesOrder(builder);
order.UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
order.UserGamesOrderGetResponse.addFound(builder, true);
order.UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset =
order.UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(offset);
return {
resultCode: "ok",
payloadBytes: builder.asUint8Array(),
};
test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
const { fakeFetchClient } = await import("./helpers/fake-order-client");
const { client } = fakeFetchClient(GAME_ID, [
{
kind: "planetRename",
id: "hydr-1",
planetNumber: 7,
name: "Hydrated",
},
};
], 7);
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
expect(store.needsServerHydration).toBe(true);
await store.hydrateFromServer({
client: fakeClient as never,
turn: 5,
});
await store.hydrateFromServer({ client, turn: 5 });
expect(store.commands).toHaveLength(1);
expect(store.commands[0]!.id).toBe("hydr-1");
expect(store.updatedAt).toBe(7);
expect(store.needsServerHydration).toBe(false);
expect(store.statuses["hydr-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("hydrate empties the local cache when server returns found=false", async () => {
// First seed a local draft.
const seeded = new OrderDraftStore();
await seeded.init({ cache, gameId: GAME_ID });
await seeded.add({
kind: "planetRename",
id: "stale",
planetNumber: 1,
name: "Stale",
});
seeded.dispose();
const { fakeFetchClient } = await import("./helpers/fake-order-client");
const { client } = fakeFetchClient(GAME_ID, [], 0, false);
const reload = new OrderDraftStore();
await reload.init({ cache, gameId: GAME_ID });
// Local cache shows the stale entry until the server speaks up.
expect(reload.commands).toHaveLength(1);
await reload.hydrateFromServer({ client, turn: 5 });
expect(reload.commands).toEqual([]);
expect(reload.syncStatus).toBe("synced");
reload.dispose();
});
});
describe("OrderDraftStore auto-sync", () => {
test("add triggers submitOrder with the full draft", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(handle.calls).toHaveLength(1);
expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("remove of last command sends an empty cmd[] to the server", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await store.remove("id-1");
await handle.waitForCalls(2);
expect(handle.calls[1]!.commandIds).toEqual([]);
expect(store.commands).toEqual([]);
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("rapid mutations coalesce into the latest draft", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok", { delayMs: 10 });
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await store.add({
kind: "planetRename",
id: "id-2",
planetNumber: 2,
name: "Mars",
});
await store.remove("id-1");
// Wait until the store reaches a steady "synced" state. The
// in-flight first call carries [id-1], the coalesced retry
// reflects the post-remove draft.
await waitFor(() => {
expect(store.syncStatus).toBe("synced");
expect(store.statuses["id-2"]).toBe("applied");
});
expect(handle.calls.length).toBeGreaterThanOrEqual(2);
const last = handle.calls[handle.calls.length - 1]!;
expect(last.commandIds).toEqual(["id-2"]);
expect(store.statuses["id-1"]).toBeUndefined();
store.dispose();
});
test("non-ok response marks every in-flight command as rejected", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "rejected");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.statuses["id-1"]).toBe("rejected");
expect(store.syncStatus).toBe("error");
store.dispose();
});
test("forceSync re-runs the pipeline after a previous failure", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "rejected");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("error");
handle.setOutcome("ok");
store.forceSync();
await handle.waitForCalls(2);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("mutations made before bindClient still sync once client is bound", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
// Mutation lands before the client is wired — the layout
// can't always sequence init → bindClient → mutate, e.g.
// when bind happens after a slow `Promise.all`.
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
expect(handle.calls).toHaveLength(0);
store.bindClient(handle.client);
store.forceSync();
await handle.waitForCalls(1);
expect(store.statuses["id-1"]).toBe("applied");
store.dispose();
});
});
+15 -2
View File
@@ -40,6 +40,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 4000,
planetCount: planets.length,
planets,
race: "",
};
}
@@ -83,7 +84,7 @@ describe("applyOrderOverlay", () => {
expect(out.planets[0]!.name).toBe("Pending");
});
test("skips unsubmitted statuses (draft/valid/invalid/rejected)", () => {
test("skips draft / invalid / rejected statuses", () => {
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = {
kind: "planetRename",
@@ -91,12 +92,24 @@ describe("applyOrderOverlay", () => {
planetNumber: 1,
name: "Tentative",
};
for (const status of ["draft", "valid", "invalid", "rejected"] as const) {
for (const status of ["draft", "invalid", "rejected"] as const) {
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
expect(out.planets[0]!.name).toBe("Earth");
}
});
test("applies on `valid` so the player sees their committed intent immediately", () => {
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = {
kind: "planetRename",
id: "cmd-1",
planetNumber: 1,
name: "Pending-Sync",
};
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "valid" });
expect(out.planets[0]!.name).toBe("Pending-Sync");
});
test("ignores rename for missing planet (visibility lost)", () => {
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = {
+109 -153
View File
@@ -1,53 +1,33 @@
// Component coverage for the Phase 14 order-tab submit flow. Drives
// the tab against an in-memory `OrderDraftStore`, a synthetic
// `GalaxyClient`, and a stubbed `GameStateStore.refresh`. Every
// case asserts both the rendered DOM (status badges, button state)
// and the side effect on the draft store (per-command status flips).
// Component coverage for the Phase 14 order tab. The Submit button
// has been retired — every successful `add` / `remove` triggers
// `OrderDraftStore.scheduleSync`, so the tab is mostly a status
// surface. Tests assert the per-row status badge transitions and
// the bottom-bar sync state.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { Builder } from "flatbuffers";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
} from "../src/lib/game-state.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
GalaxyClientHolder,
} from "../src/lib/galaxy-client-context.svelte";
import { i18n } from "../src/lib/i18n/index.svelte";
import { uuidToHiLo } from "../src/api/game-state";
import type { GalaxyClient } from "../src/api/galaxy-client";
import type { OrderCommand } from "../src/sync/order-types";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
import { openGalaxyDB } from "../src/platform/store/idb";
import { recordingClient } from "./helpers/fake-order-client";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
let dbName: string;
let cache: Cache;
beforeEach(async () => {
dbName = `galaxy-order-tab-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
i18n.resetForTests("en");
});
@@ -61,162 +41,138 @@ afterEach(async () => {
});
});
interface Setup {
context: Map<unknown, unknown>;
draft: OrderDraftStore;
gameState: GameStateStore;
clientHolder: GalaxyClientHolder;
exec: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
}
function buildResponse(
commands: { id: string; applied: boolean | null; errorCode: number | null }[],
updatedAt: number,
): Uint8Array {
const builder = new Builder(256);
const itemOffsets = commands.map((c) => {
const cmdIdOffset = builder.createString(c.id);
const nameOffset = builder.createString("ignored");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(0),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
if (c.errorCode !== null) {
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
}
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrderResponse.createCommandsVector(
builder,
itemOffsets,
);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(updatedAt));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}
async function makeSetup(commands: OrderCommand[]): Promise<Setup> {
async function makeDraft(
commands: OrderCommand[],
): Promise<{ draft: OrderDraftStore; context: Map<unknown, unknown> }> {
const cache = new IDBCache(db);
const draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
for (const cmd of commands) {
await draft.add(cmd);
}
const gameState = new GameStateStore();
gameState.gameId = GAME_ID;
gameState.status = "ready";
const refresh = vi.fn(async () => {});
gameState.refresh = refresh as unknown as typeof gameState.refresh;
const clientHolder = new GalaxyClientHolder();
const exec = vi.fn(async (_messageType: string, _payload: Uint8Array) => ({
resultCode: "ok",
payloadBytes: buildResponse(
commands.map((cmd) => ({
id: cmd.id,
applied: true,
errorCode: null,
})),
17,
),
}));
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
[GAME_STATE_CONTEXT_KEY, gameState],
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
]);
return { context, draft, gameState, clientHolder, exec, refresh };
return { draft, context };
}
describe("order-tab", () => {
test("renders the empty state when the draft has no commands", async () => {
const { context } = await makeSetup([]);
const { draft, context } = await makeDraft([]);
const ui = render(OrderTab, { context });
expect(ui.getByTestId("order-empty")).toBeVisible();
expect(ui.queryByTestId("order-submit")).toBeNull();
expect(ui.queryByTestId("order-list")).toBeNull();
// The sync bar still renders so the user can see the
// idle / synced / error transitions.
expect(ui.getByTestId("order-sync")).toBeVisible();
draft.dispose();
});
test("Submit is disabled when every entry is invalid", async () => {
const { context } = await makeSetup([
test("invalid command shows the invalid status badge", async () => {
const { draft, context } = await makeDraft([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "" },
]);
const ui = render(OrderTab, { context });
const submit = ui.getByTestId("order-submit");
expect(submit).toBeDisabled();
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"invalid",
);
draft.dispose();
});
test("Submit posts every valid command and applies returned statuses", async () => {
const { context, draft, exec, refresh } = await makeSetup([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
]);
test("auto-sync flips the row to applied and the sync bar to synced", async () => {
const handle = recordingClient(GAME_ID, "ok");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client);
const ui = render(OrderTab, { context });
const submit = ui.getByTestId("order-submit");
expect(submit).not.toBeDisabled();
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("valid");
await fireEvent.click(submit);
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await waitFor(() => {
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"applied",
);
});
await waitFor(() => {
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
});
expect(handle.calls).toHaveLength(1);
expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
draft.dispose();
});
test("removing the last command sends an empty cmd[] PUT", async () => {
const handle = recordingClient(GAME_ID, "ok");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client);
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
const ui = render(OrderTab, { context });
await fireEvent.click(ui.getByTestId("order-command-delete-0"));
await handle.waitForCalls(2);
expect(handle.calls[1]!.commandIds).toEqual([]);
expect(draft.commands).toEqual([]);
await waitFor(() => {
expect(ui.getByTestId("order-empty")).toBeVisible();
});
draft.dispose();
});
test("non-ok response surfaces the sync error and a retry button", async () => {
const handle = recordingClient(GAME_ID, "rejected");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client);
const ui = render(OrderTab, { context });
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await waitFor(() => {
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"error",
);
});
expect(ui.getByTestId("order-sync-retry")).toBeVisible();
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"rejected",
);
// Retry the call now that the fixture answers ok.
handle.setOutcome("ok");
await fireEvent.click(ui.getByTestId("order-sync-retry"));
await handle.waitForCalls(2);
await waitFor(() => {
expect(draft.statuses["id-1"]).toBe("applied");
});
expect(exec).toHaveBeenCalledTimes(1);
expect(refresh).toHaveBeenCalledTimes(1);
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"applied",
);
});
test("Non-ok response marks every submitting entry as rejected", async () => {
const { context, draft, refresh } = await makeSetup([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
]);
const exec = vi.fn(async () => ({
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({ code: "boom", message: "down" }),
),
}));
const holder = context.get(GALAXY_CLIENT_CONTEXT_KEY) as GalaxyClientHolder;
holder.set({ executeCommand: exec } as unknown as GalaxyClient);
const ui = render(OrderTab, { context });
await fireEvent.click(ui.getByTestId("order-submit"));
await waitFor(() => {
expect(draft.statuses["id-1"]).toBe("rejected");
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
});
expect(refresh).not.toHaveBeenCalled();
expect(ui.getByTestId("order-submit-error")).toHaveTextContent("down");
});
test("Already-applied entries do not get re-submitted", async () => {
const { context, draft, exec } = await makeSetup([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
]);
draft.markSubmitting(["id-1"]);
draft.applyResults({
results: new Map([["id-1", "applied"] as const]),
updatedAt: 1,
});
const ui = render(OrderTab, { context });
const submit = ui.getByTestId("order-submit");
expect(submit).toBeDisabled();
expect(exec).not.toHaveBeenCalled();
draft.dispose();
});
});
+1
View File
@@ -19,6 +19,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
mapHeight: 4000,
planetCount: 0,
planets: [],
race: "",
...overrides,
};
}