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
+186
View File
@@ -623,3 +623,189 @@ describe("OrderDraftStore auto-sync", () => {
store.dispose();
});
});
describe("OrderDraftStore Phase 25 conflict / paused / offline", () => {
test("turn_already_closed marks the in-flight commands as conflict", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client, { getCurrentTurn: () => 7 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
expect(store.statuses["id-1"]).toBe("conflict");
expect(store.conflictBanner).not.toBeNull();
expect(store.conflictBanner?.turn).toBe(7);
expect(store.conflictBanner?.code).toBe("turn_already_closed");
store.dispose();
});
test("mutating after a conflict clears the banner and revalidates", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client, { getCurrentTurn: () => 3 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
// Adding a second command must wipe the conflict banner and
// re-validate the prior conflict-marked entry. Auto-sync
// re-fires (still seeing turn_already_closed) and the
// store ends up back in conflict for the new attempt.
handle.setOutcome("ok");
await store.add({
kind: "planetRename",
id: "id-2",
planetNumber: 2,
name: "Mars",
});
await handle.waitForCalls(2);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.statuses["id-2"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
expect(store.conflictBanner).toBeNull();
store.dispose();
});
test("game_paused outcome surfaces the pause banner and locks sync", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "game_paused");
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("paused");
expect(store.pausedBanner).not.toBeNull();
expect(store.statuses["id-1"]).toBe("valid"); // reverted, not in flight
// While paused, additional mutations should not trigger another
// submit — the queue would just hit the same wall.
const before = handle.calls.length;
store.forceSync();
await new Promise<void>((resolve) => setTimeout(resolve, 20));
expect(handle.calls.length).toBe(before);
store.dispose();
});
test("markPaused projects a push event into the store", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.markPaused({ reason: "generation_failed" });
expect(store.syncStatus).toBe("paused");
expect(store.pausedBanner?.reason).toBe("generation_failed");
store.dispose();
});
test("resetForNewTurn clears the conflict banner and rehydrates", async () => {
const { fakeFetchClient, recordingClient } = await import(
"./helpers/fake-order-client"
);
const recHandle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(recHandle.client, { getCurrentTurn: () => 5 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await recHandle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
const { client: fetchClient } = fakeFetchClient(GAME_ID, [
{
kind: "planetRename",
id: "fresh-1",
planetNumber: 9,
name: "Hydrated",
},
], 11);
await store.resetForNewTurn({ client: fetchClient, turn: 6 });
expect(store.conflictBanner).toBeNull();
expect(store.syncStatus).toBe("synced");
expect(store.commands.map((c) => c.id)).toEqual(["fresh-1"]);
expect(store.updatedAt).toBe(11);
store.dispose();
});
test("offline outcome holds the submit until online flips", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
// Stub the browser event surface so we can flip online/offline
// deterministically and assert the queue's reaction.
const listeners = new Map<string, Set<() => void>>();
let online = false;
await store.init({
cache,
gameId: GAME_ID,
queue: {
onlineProbe: () => online,
addEventListener: (event, handler) => {
let bucket = listeners.get(event);
if (bucket === undefined) {
bucket = new Set();
listeners.set(event, bucket);
}
bucket.add(handler);
},
removeEventListener: (event, handler) => {
listeners.get(event)?.delete(handler);
},
onOnline: () => undefined,
},
});
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
// Submission must NOT have left the queue while offline.
await new Promise<void>((resolve) => setTimeout(resolve, 20));
expect(handle.calls).toHaveLength(0);
expect(store.syncStatus).toBe("offline");
expect(store.statuses["id-1"]).toBe("valid");
// Flip online and fire the browser `online` event; the queue's
// onOnline callback (set inside OrderDraftStore) schedules a
// fresh sync.
online = true;
const onlineBucket = listeners.get("online");
onlineBucket?.forEach((h) => h());
await handle.waitForCalls(1);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
});