ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol

Backend now owns the turn-cutoff and pause guards the order tab
relies on: the scheduler flips runtime_status between
generation_in_progress and running around every engine tick, a
failed tick auto-pauses the game through OnRuntimeSnapshot, and a
new game.paused notification kind fans out alongside
game.turn.ready. The user-games handlers reject submits with
HTTP 409 turn_already_closed or game_paused depending on the
runtime state.

UI delegates auto-sync to a new OrderQueue: offline detection,
single retry on reconnect, conflict / paused classification.
OrderDraftStore surfaces conflictBanner / pausedBanner runes,
clears them on local mutation or on a game.turn.ready push via
resetForNewTurn. The order tab renders the matching banners and
the new conflict per-row badge; i18n bundles cover en + ru.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-11 22:00:16 +02:00
parent bbdcc36e05
commit 2ca47eb4df
35 changed files with 2539 additions and 143 deletions
+54
View File
@@ -175,4 +175,58 @@ describe("order-tab", () => {
});
draft.dispose();
});
test("turn_already_closed surfaces the conflict banner with the turn", async () => {
const handle = recordingClient(GAME_ID, "turn_already_closed");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client, { getCurrentTurn: () => 12 });
const ui = render(OrderTab, { context });
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await waitFor(() => {
const banner = ui.getByTestId("order-conflict-banner");
expect(banner).toBeVisible();
expect(banner).toHaveTextContent("Turn 12");
expect(banner).toHaveAttribute("data-conflict-turn", "12");
});
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("conflict");
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"conflict",
);
draft.dispose();
});
test("game_paused surfaces the paused banner and blocks retry", async () => {
const handle = recordingClient(GAME_ID, "game_paused");
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-paused-banner")).toBeVisible();
});
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"paused",
);
// No retry button is shown for paused state.
expect(ui.queryByTestId("order-sync-retry")).toBeNull();
draft.dispose();
});
});