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
@@ -229,11 +229,13 @@ fresh.
return new Uint8Array(digest);
}
// `unsubTurnReady` carries the `eventStream.on(...)` disposer for
// the game-scoped turn-ready handler. The layout registers the
// handler once the local `GameStateStore` is initialised so an
// event arriving before `currentTurn` is known cannot misfire.
// `unsubTurnReady` / `unsubGamePaused` carry the
// `eventStream.on(...)` disposers for the game-scoped push
// handlers. The layout registers them once the local
// `GameStateStore` is initialised so an event arriving before
// `currentTurn` is known cannot misfire.
let unsubTurnReady: (() => void) | null = null;
let unsubGamePaused: (() => void) | null = null;
const turnReadyDecoder = new TextDecoder("utf-8");
function parseTurnReadyPayload(
@@ -261,6 +263,27 @@ fresh.
}
}
function parseGamePausedPayload(
event: VerifiedEvent,
): { gameId: string; reason: string } | null {
try {
const text = turnReadyDecoder.decode(event.payloadBytes);
const json: unknown = JSON.parse(text);
if (typeof json !== "object" || json === null) {
return null;
}
const record = json as Record<string, unknown>;
const eventGameId = record.game_id;
if (typeof eventGameId !== "string") {
return null;
}
const reason = typeof record.reason === "string" ? record.reason : "";
return { gameId: eventGameId, reason };
} catch {
return null;
}
}
let activeTurnReadyToastId: string | null = null;
$effect(() => {
@@ -340,20 +363,42 @@ fresh.
// while `gameState.init` is still in flight is not
// dropped by the singleton stream. `markPendingTurn`
// already protects against turns that do not advance
// past the current snapshot.
// past the current snapshot. Phase 25: a turn-ready
// frame arriving while the draft is in `conflict` or
// `paused` state also resets the draft and rehydrates
// from the server for the new turn — the old commands
// became history at the cutoff.
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
const parsed = parseTurnReadyPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
return;
}
gameState.markPendingTurn(parsed.turn);
if (
orderDraft.syncStatus === "conflict" ||
orderDraft.syncStatus === "paused"
) {
void orderDraft.resetForNewTurn({
client,
turn: parsed.turn,
});
}
});
unsubGamePaused = eventStream.on("game.paused", (event) => {
const parsed = parseGamePausedPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
return;
}
orderDraft.markPaused({ reason: parsed.reason });
});
await Promise.all([
gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }),
]);
galaxyClient.set(client);
orderDraft.bindClient(client);
orderDraft.bindClient(client, {
getCurrentTurn: () => gameState.currentTurn,
});
// The server is always polled at game boot — its
// stored order may be fresher than the local cache
// (e.g. user is on a new device), and an offline
@@ -375,6 +420,10 @@ fresh.
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
gameState.dispose();
orderDraft.dispose();
selection.dispose();