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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user