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
@@ -37,6 +37,7 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
submitting: "game.sidebar.order.status.submitting",
applied: "game.sidebar.order.status.applied",
rejected: "game.sidebar.order.status.rejected",
conflict: "game.sidebar.order.status.conflict",
};
function describe(cmd: OrderCommand): string {
@@ -152,6 +153,32 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
<section class="tool" data-testid="sidebar-tool-order">
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
{#if draft !== undefined && draft.pausedBanner !== null}
<div
class="banner banner-paused"
data-testid="order-paused-banner"
data-paused-reason={draft.pausedBanner.reason}
role="status"
>
{i18n.t("game.sidebar.order.paused.banner")}
</div>
{/if}
{#if draft !== undefined && draft.conflictBanner !== null}
<div
class="banner banner-conflict"
data-testid="order-conflict-banner"
data-conflict-turn={draft.conflictBanner.turn ?? ""}
role="status"
>
{#if draft.conflictBanner.turn !== null}
{i18n.t("game.sidebar.order.conflict.banner", {
turn: String(draft.conflictBanner.turn),
})}
{:else}
{i18n.t("game.sidebar.order.conflict.banner_no_turn")}
{/if}
</div>
{/if}
{#if draft === undefined || draft.commands.length === 0}
<p class="empty" data-testid="order-empty">
{i18n.t("game.sidebar.empty.order")}
@@ -202,6 +229,12 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
{i18n.t("game.sidebar.order.sync.error", {
message: draft.syncError ?? "",
})}
{:else if draft.syncStatus === "offline"}
{i18n.t("game.sidebar.order.sync.offline")}
{:else if draft.syncStatus === "conflict"}
{i18n.t("game.sidebar.order.sync.conflict")}
{:else if draft.syncStatus === "paused"}
{i18n.t("game.sidebar.order.sync.paused")}
{:else}
{i18n.t("game.sidebar.order.sync.idle")}
{/if}
@@ -286,6 +319,27 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
color: #6d8cff;
border-color: #2f3f6d;
}
.status-conflict {
color: #d99a4b;
border-color: #6d4a2f;
}
.banner {
margin: 0 0 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.3;
}
.banner-conflict {
color: #f1bf78;
background: #2a1f10;
border: 1px solid #6d4a2f;
}
.banner-paused {
color: #d4d4d4;
background: #1a1f2a;
border: 1px solid #2f3f55;
}
.delete {
font: inherit;
font-size: 0.85rem;
@@ -317,6 +371,15 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
.sync-syncing {
color: #6d8cff;
}
.sync-offline {
color: #b9a566;
}
.sync-conflict {
color: #d99a4b;
}
.sync-paused {
color: #d4d4d4;
}
.sync-retry {
font: inherit;
font-size: 0.8rem;