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
+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";