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:
+120
-26
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user