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:
@@ -128,13 +128,20 @@ const en = {
|
||||
"game.sidebar.order.sync.in_flight": "syncing…",
|
||||
"game.sidebar.order.sync.synced": "synced with server",
|
||||
"game.sidebar.order.sync.error": "sync failed: {message}",
|
||||
"game.sidebar.order.sync.offline": "queued — offline, will retry on reconnect",
|
||||
"game.sidebar.order.sync.conflict": "turn closed before submit",
|
||||
"game.sidebar.order.sync.paused": "game paused — orders disabled",
|
||||
"game.sidebar.order.sync.retry": "retry",
|
||||
"game.sidebar.order.conflict.banner": "Turn {turn} closed before your order was accepted. Edit and resubmit.",
|
||||
"game.sidebar.order.conflict.banner_no_turn": "Turn closed before your order was accepted. Edit and resubmit.",
|
||||
"game.sidebar.order.paused.banner": "Game paused. Orders are not accepted until it resumes.",
|
||||
"game.sidebar.order.status.draft": "draft",
|
||||
"game.sidebar.order.status.valid": "valid",
|
||||
"game.sidebar.order.status.invalid": "invalid",
|
||||
"game.sidebar.order.status.submitting": "submitting",
|
||||
"game.sidebar.order.status.applied": "applied",
|
||||
"game.sidebar.order.status.rejected": "rejected",
|
||||
"game.sidebar.order.status.conflict": "conflict",
|
||||
"game.sidebar.order.label.placeholder": "{label}",
|
||||
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
|
||||
"game.sidebar.order.label.planet_production": "set production on planet {planet} → {target}",
|
||||
|
||||
@@ -129,13 +129,20 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.sidebar.order.sync.in_flight": "синхронизация…",
|
||||
"game.sidebar.order.sync.synced": "сохранено на сервере",
|
||||
"game.sidebar.order.sync.error": "ошибка синхронизации: {message}",
|
||||
"game.sidebar.order.sync.offline": "очередь — нет связи, повторим при восстановлении",
|
||||
"game.sidebar.order.sync.conflict": "ход закрылся до отправки",
|
||||
"game.sidebar.order.sync.paused": "игра на паузе — приказы не принимаются",
|
||||
"game.sidebar.order.sync.retry": "повторить",
|
||||
"game.sidebar.order.conflict.banner": "Ход {turn} закрылся до того, как приказ был принят. Отредактируй и отправь ещё раз.",
|
||||
"game.sidebar.order.conflict.banner_no_turn": "Ход закрылся до того, как приказ был принят. Отредактируй и отправь ещё раз.",
|
||||
"game.sidebar.order.paused.banner": "Игра на паузе. Приказы не принимаются, пока она не возобновится.",
|
||||
"game.sidebar.order.status.draft": "черновик",
|
||||
"game.sidebar.order.status.valid": "готова",
|
||||
"game.sidebar.order.status.invalid": "ошибка",
|
||||
"game.sidebar.order.status.submitting": "отправка",
|
||||
"game.sidebar.order.status.applied": "принята",
|
||||
"game.sidebar.order.status.rejected": "отклонена",
|
||||
"game.sidebar.order.status.conflict": "конфликт",
|
||||
"game.sidebar.order.label.placeholder": "{label}",
|
||||
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
|
||||
"game.sidebar.order.label.planet_production": "сменить производство планеты {planet} → {target}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user