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
+120 -26
View File
@@ -2671,43 +2671,137 @@ Targeted tests (delivered):
`game.turn.ready` frame surfaces the toast, manual dismiss
clears it).
## Phase 25. Sync Protocol — Order Queue, Retry, Conflict
## Phase 25. Sync Protocol — Turn Cutoff, Conflict, Auto-Pause
Status: pending.
Status: in progress.
Goal: make the order draft survive network failures and turn cutoffs
gracefully, with explicit user feedback on conflicts.
Goal: make the order draft survive transient connectivity issues
**and** the real turn-cutoff machinery, with explicit user feedback
on conflicts and on admin-pause states. The phase is intentionally
cross-module: the UI side leans on a backend turn-cutoff guard and
auto-pause that did not exist before; both land together so the
contract is end-to-end.
Artifacts:
Decisions baked in during implementation:
- `ui/frontend/src/sync/order-queue.ts` send loop: on disconnect, hold
the most recent submit; on reconnect, retry once; on persistent
failure, surface error to the order tab
- conflict detection: if the server returns `turn_already_closed` for
a submit, mark the entire draft as `conflict` and surface a
`Turn N closed before your order was accepted. Edit and resubmit.`
banner in the order tab
- topic doc `ui/docs/sync-protocol.md` covering queue semantics,
retry budgets, and conflict UX
- Turn-cutoff enforcement lives in `backend` (not in `game-engine`).
The scheduler flips `runtime_status` to `generation_in_progress`
before each engine tick and back to `running` after; the
user-games handlers reject every command/order in
non-running runtime states.
- A failed engine tick auto-pauses the game (`running → paused`)
through `lobby.OnRuntimeSnapshot`, and the lobby publishes a
matching `game.paused` push event. Admin resume remains the
only way out of `paused`.
- The wire-level error codes are `turn_already_closed` (cutoff
conflict) and `game_paused` (paused / starting / finished / removed).
Gateway carries them through `projectUserBackendError` unchanged.
- The UI draft store delegates to a new `OrderQueue` (single-slot
pending, single retry on reconnect via `onOnline` callback). On
`game.turn.ready` after a conflict / pause, the layout calls
`OrderDraftStore.resetForNewTurn` which wipes the draft and
re-hydrates from the server for the new turn (old commands are
preserved server-side and can be read back via
`user.games.order.get?turn=N`).
Dependencies: Phases 14, 24.
Backend artifacts:
- `backend/internal/notification/catalog.go`: new
`KindGamePaused = "game.paused"` and `catalog`/`SupportedKinds`
entries; matching `NotificationGamePaused` constant in
`backend/internal/lobby/lobby.go`; CHECK-constraint widened in
`backend/internal/postgres/migrations/00001_init.sql`.
- `backend/internal/lobby/runtime_hooks.go`:
`nextStatusFromSnapshot` flips `running → paused` on
`engine_unreachable` / `generation_failed`; new
`publishGamePaused` mirrors `publishTurnReady`, idempotency key
`paused:<game_id>:<turn>`, payload `{game_id, turn, reason}`.
- `backend/internal/runtime/scheduler.go`: `tick` wraps the engine
call with `generation_in_progress` / `running` flips and forwards
failure snapshots to lobby through
`Service.publishFailureSnapshot`.
- `backend/internal/runtime/service.go`: `CheckOrdersAccept` plus
the pure `OrdersAcceptStatus` helper used by both `Orders` and
`Commands` user-games handlers.
- `backend/internal/server/httperr/httperr.go`: new
`CodeTurnAlreadyClosed`, `CodeGamePaused`; openapi.yaml
`ErrorBody.code` enum extended.
- `backend/internal/server/handlers_user_games.go`:
`requireOrdersOpen` runs before forwarding, maps sentinels to
HTTP 409 + the matching code.
UI artifacts:
- `ui/frontend/src/sync/order-queue.svelte.ts` (new) — `OrderQueue`
class with offline detection, classification of
`turn_already_closed` / `game_paused`, dependency-injected
online probe + event listeners. Pure-function helper
`classifyResult` reused from tests.
- `ui/frontend/src/sync/order-types.ts` — `CommandStatus` gains
`conflict`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — wires
`OrderQueue` through `runSync`, adds `conflict` / `paused` /
`offline` to `SyncStatus`, plus `conflictBanner` /
`pausedBanner` runes, `markPaused`, `resetForNewTurn`,
`clearConflictForMutation`, sticky-`paused` guard in
`hydrateFromServer`. `bindClient(client, { getCurrentTurn })`
lets the conflict banner interpolate the turn number.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — renders
conflict / paused banners and the new `conflict` per-row badge;
status bar carries the offline / conflict / paused copy.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new keys for
`sync.{offline,conflict,paused}`, `conflict.banner`
(with `{turn}` interpolation) plus `banner_no_turn` fallback,
`paused.banner`, `status.conflict`.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
subscribes to `game.paused`; `game.turn.ready` handler now
triggers `resetForNewTurn` when the prior `syncStatus` was
`conflict` / `paused`. `bindClient` is invoked with
`getCurrentTurn: () => gameState.currentTurn`.
- `ui/docs/sync-protocol.md` (new) — send-loop semantics, retry
budget, conflict and paused UX, recovery paths.
- `ui/docs/order-composer.md` — stale Phase 25 paragraph
replaced with a pointer to the new topic doc; state-machine
diagram extended with the `conflict` transition.
Dependencies: Phases 14, 24; backend notification / lobby /
runtime modules.
Acceptance criteria:
- submitting an order while offline queues it and submits successfully
on reconnect;
- a turn cutoff between draft and submit produces a visible conflict
banner with no data loss;
- the order tab clearly distinguishes `draft`, `submitting`,
`accepted`, `rejected`, `conflict` states per command.
- submitting an order while offline queues it and submits
successfully on reconnect (one attempt on the next `online`
event, no inline retry storm);
- a turn cutoff between draft and submit produces a visible
conflict banner with the turn number; the local draft is
preserved until the next `game.turn.ready`, then the layout
wipes it and re-hydrates from the server for `turn = N+1`;
- a runtime failure during generation flips the game into
`paused`, emits `game.paused`, and the order tab shows the
pause banner; submits are blocked until the next
`game.turn.ready` clears the state;
- the order tab clearly distinguishes `draft`, `valid`,
`invalid`, `submitting`, `applied`, `rejected`, and
`conflict` states per command.
Targeted tests:
- Vitest unit tests for `order-queue` covering all state transitions;
- Playwright e2e: simulate network drop using Playwright's offline
mode, submit an order, restore network, confirm submission;
- regression test: force a turn cutoff during submit, assert conflict
banner appears.
- Backend: `runtime_hooks_unit_test.go` for
`nextStatusFromSnapshot`, `orders_accept_test.go` for the
per-record decision, plus existing testcontainer-backed
`runtime_hooks_test.go` covering the published intent. Catalog
/ event tests extended with `game.paused`.
- UI Vitest: `tests/order-queue.test.ts` (classification +
offline plumbing), extended `tests/order-draft.test.ts`
(conflict marks commands, mutation clears banner, pause
blocks sync, offline holds + flushes on `online`,
`resetForNewTurn` re-hydrates), extended
`tests/order-tab.test.ts` (banner DOM + sync-status
attribute), extended `tests/events.test.ts` (`game.paused`
dispatch).
- Playwright e2e: `tests/e2e/order-sync.spec.ts` — conflict
banner on `turn_already_closed` reply and paused banner on
the signed `game.paused` frame.
## Phase 26. History Mode