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
+57 -18
View File
@@ -33,10 +33,25 @@ interface RecordedCall {
commandIds: string[];
}
/**
* RecordingOutcome enumerates the synthetic server reactions a test
* can drive through `recordingClient.setOutcome`. Phase 25 adds the
* `turn_already_closed` and `game_paused` codes (the order-queue
* classifies them into `conflict` / `paused` outcomes) and `throw`
* which lets the test exercise the network-error branch of
* `OrderQueue.send`.
*/
export type RecordingOutcome =
| "ok"
| "rejected"
| "turn_already_closed"
| "game_paused"
| "throw";
interface RecordingHandle {
client: GalaxyClient;
calls: RecordedCall[];
setOutcome(outcome: "ok" | "rejected"): void;
setOutcome(outcome: RecordingOutcome): void;
waitForCalls(n: number): Promise<void>;
waitForIdle(): Promise<void>;
}
@@ -51,11 +66,11 @@ interface RecordingHandle {
*/
export function recordingClient(
gameId: string,
initialOutcome: "ok" | "rejected",
initialOutcome: RecordingOutcome,
options: { delayMs?: number } = {},
): RecordingHandle {
const calls: RecordedCall[] = [];
let outcome: "ok" | "rejected" = initialOutcome;
let outcome: RecordingOutcome = initialOutcome;
let inFlight = 0;
const waiters: (() => void)[] = [];
@@ -81,21 +96,45 @@ export function recordingClient(
if (id !== null) commandIds.push(id);
}
calls.push({ messageType, commandIds });
if (outcome === "ok") {
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
switch (outcome) {
case "ok":
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
case "turn_already_closed":
return {
resultCode: "turn_already_closed",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "turn_already_closed",
message: "turn closed before submit",
}),
),
};
case "game_paused":
return {
resultCode: "game_paused",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "game_paused",
message: "game is paused",
}),
),
};
case "throw":
throw new Error("network down");
default:
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
throw new Error(`unexpected messageType ${messageType}`);
} finally {
@@ -113,7 +152,7 @@ export function recordingClient(
return {
client,
calls,
setOutcome(next: "ok" | "rejected") {
setOutcome(next: RecordingOutcome) {
outcome = next;
},
async waitForCalls(n: number) {