diff --git a/backend/README.md b/backend/README.md index bac53b3..9e1ddc7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -333,20 +333,38 @@ cannot guarantee. | `runtime.image_pull_failed` | admin email | `game_id`, `image_ref` | | `runtime.container_start_failed` | admin email | `game_id` | | `runtime.start_config_invalid` | admin email | `game_id`, `reason` | +| `game.turn.ready` | push | `game_id`, `turn` | +| `game.paused` | push | `game_id`, `turn`, `reason` | Admin-channel kinds (`runtime.*`) deliver email to `BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those routes land in `notification_routes` with `status='skipped'` and the operator log line records the configuration miss. -`game.turn.ready` is emitted by `lobby.Service.OnRuntimeSnapshot` -(`backend/internal/lobby/runtime_hooks.go`) whenever the engine's -`current_turn` advances. The intent targets every active membership -of the game, uses idempotency key `turn-ready::`, and -carries the JSON payload `{game_id, turn}`. The catalog routes it -through the push channel only — per-turn email would be spam — so +`game.turn.ready` and `game.paused` are emitted by +`lobby.Service.OnRuntimeSnapshot` +(`backend/internal/lobby/runtime_hooks.go`): + +- `game.turn.ready` fires whenever the engine's `current_turn` + advances. Idempotency key `turn-ready::`, JSON + payload `{game_id, turn}`. +- `game.paused` fires whenever the same hook flips the game + `running → paused` because a runtime snapshot landed with + `engine_unreachable` / `generation_failed`. Idempotency key + `paused::`, JSON payload + `{game_id, turn, reason}` (reason carries the runtime status + that triggered the transition). The runtime scheduler + (`backend/internal/runtime/scheduler.go`) forwards the failing + snapshot through `Service.publishFailureSnapshot` so a single + failing tick reliably reaches lobby. + +Both kinds target every active membership and route through the +push channel only — per-turn / per-pause email would be spam — so the UI's signed `SubscribeEvents` stream -(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery path. +(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery +path. The order tab consumes them via +`OrderDraftStore.resetForNewTurn` / `markPaused` +(`ui/docs/sync-protocol.md`). The remaining `game.*` (`game.started`, `game.generation.failed`, `game.finished`) and `mail.dead_lettered` are reserved kinds without diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index d3a3487..25a0783 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -110,6 +110,7 @@ const ( NotificationLobbyRaceNamePending = "lobby.race_name.pending" NotificationLobbyRaceNameExpired = "lobby.race_name.expired" NotificationGameTurnReady = "game.turn.ready" + NotificationGamePaused = "game.paused" ) // Deps aggregates every collaborator the lobby Service depends on. diff --git a/backend/internal/lobby/runtime_hooks.go b/backend/internal/lobby/runtime_hooks.go index 1710965..566e418 100644 --- a/backend/internal/lobby/runtime_hooks.go +++ b/backend/internal/lobby/runtime_hooks.go @@ -37,6 +37,7 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps if err != nil { return err } + transitionedToPaused := false if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition { switch next { case GameStatusFinished: @@ -53,12 +54,18 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps return err } updated = rec + if next == GameStatusPaused { + transitionedToPaused = true + } } } s.deps.Cache.PutGame(updated) if merged.CurrentTurn > prevTurn { s.publishTurnReady(ctx, gameID, merged.CurrentTurn) } + if transitionedToPaused { + s.publishGamePaused(ctx, gameID, merged.CurrentTurn, snapshot.RuntimeStatus) + } return nil } @@ -106,6 +113,56 @@ func (s *Service) publishTurnReady(ctx context.Context, gameID uuid.UUID, turn i } } +// publishGamePaused fans out a `game.paused` notification to every +// active member of the game when the lobby flips the game to +// `paused` in reaction to a runtime snapshot (typically a failed +// turn generation). The intent is best-effort: a publisher failure +// is logged at warn level and does not abort the snapshot +// bookkeeping. Idempotency is anchored on (game_id, turn) so a +// repeated `generation_failed` snapshot for the same turn collapses +// into a single notification at the notification.Submit boundary. +// +// reason carries the raw runtime status that triggered the pause +// (`engine_unreachable` / `generation_failed`); the UI displays a +// status-agnostic banner today but the payload is preserved so a +// future revision of the order tab can differentiate. +func (s *Service) publishGamePaused(ctx context.Context, gameID uuid.UUID, turn int32, reason string) { + memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID) + if err != nil { + s.deps.Logger.Warn("game-paused notification: list memberships failed", + zap.String("game_id", gameID.String()), + zap.Int32("turn", turn), + zap.Error(err)) + return + } + recipients := make([]uuid.UUID, 0, len(memberships)) + for _, m := range memberships { + if m.Status != MembershipStatusActive { + continue + } + recipients = append(recipients, m.UserID) + } + if len(recipients) == 0 { + return + } + intent := LobbyNotification{ + Kind: NotificationGamePaused, + IdempotencyKey: fmt.Sprintf("paused:%s:%d", gameID, turn), + Recipients: recipients, + Payload: map[string]any{ + "game_id": gameID.String(), + "turn": turn, + "reason": reason, + }, + } + if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil { + s.deps.Logger.Warn("game-paused notification failed", + zap.String("game_id", gameID.String()), + zap.Int32("turn", turn), + zap.Error(pubErr)) + } +} + // OnGameFinished completes the game lifecycle: marks the game as // `finished`, evaluates capable-finish per active member, and // transitions reservation rows to either `pending_registration` @@ -278,13 +335,28 @@ func mergeRuntimeSnapshot(prev, next RuntimeSnapshot) RuntimeSnapshot { // nextStatusFromSnapshot maps the runtime-reported runtime status into // a lobby status transition. Returns (next, true) when the lobby // status must change; (current, false) otherwise. +// +// The map intentionally distinguishes the pre-running boot path +// (`starting → start_failed`) from the in-flight failure path +// (`running → paused`). Paused games can be resumed by the admin via +// the explicit `/resume` transition; the runtime keeps the engine +// container alive, the scheduler short-circuits ticks while paused, +// and any user-games command/order is rejected by the order handler +// with `turn_already_closed` until the game resumes. func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) { switch snapshot.RuntimeStatus { case "running": if currentStatus == GameStatusStarting { return GameStatusRunning, true } - case "engine_unreachable", "start_failed", "generation_failed": + case "engine_unreachable", "generation_failed": + if currentStatus == GameStatusStarting { + return GameStatusStartFailed, true + } + if currentStatus == GameStatusRunning { + return GameStatusPaused, true + } + case "start_failed": if currentStatus == GameStatusStarting { return GameStatusStartFailed, true } diff --git a/backend/internal/lobby/runtime_hooks_unit_test.go b/backend/internal/lobby/runtime_hooks_unit_test.go new file mode 100644 index 0000000..8ae5825 --- /dev/null +++ b/backend/internal/lobby/runtime_hooks_unit_test.go @@ -0,0 +1,127 @@ +package lobby + +import "testing" + +// TestNextStatusFromSnapshot covers the pure status-mapping function +// that drives `OnRuntimeSnapshot`'s lifecycle transitions. The Phase +// 25 contribution is the `running → paused` branch on +// `engine_unreachable` / `generation_failed`: the order handler relies +// on the `paused` game status to reject late submits with +// `turn_already_closed`. +func TestNextStatusFromSnapshot(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + currentStatus string + runtimeStatus string + wantStatus string + wantTransit bool + }{ + { + name: "starting then running flips to running", + currentStatus: GameStatusStarting, + runtimeStatus: "running", + wantStatus: GameStatusRunning, + wantTransit: true, + }, + { + name: "running on running snapshot does not transit", + currentStatus: GameStatusRunning, + runtimeStatus: "running", + wantStatus: GameStatusRunning, + wantTransit: false, + }, + { + name: "starting then engine_unreachable flips to start_failed", + currentStatus: GameStatusStarting, + runtimeStatus: "engine_unreachable", + wantStatus: GameStatusStartFailed, + wantTransit: true, + }, + { + name: "starting then generation_failed flips to start_failed", + currentStatus: GameStatusStarting, + runtimeStatus: "generation_failed", + wantStatus: GameStatusStartFailed, + wantTransit: true, + }, + { + name: "running then engine_unreachable flips to paused", + currentStatus: GameStatusRunning, + runtimeStatus: "engine_unreachable", + wantStatus: GameStatusPaused, + wantTransit: true, + }, + { + name: "running then generation_failed flips to paused", + currentStatus: GameStatusRunning, + runtimeStatus: "generation_failed", + wantStatus: GameStatusPaused, + wantTransit: true, + }, + { + name: "paused stays paused on repeated failed snapshot", + currentStatus: GameStatusPaused, + runtimeStatus: "generation_failed", + wantStatus: GameStatusPaused, + wantTransit: false, + }, + { + name: "starting then start_failed flips to start_failed", + currentStatus: GameStatusStarting, + runtimeStatus: "start_failed", + wantStatus: GameStatusStartFailed, + wantTransit: true, + }, + { + name: "running ignores start_failed", + currentStatus: GameStatusRunning, + runtimeStatus: "start_failed", + wantStatus: GameStatusRunning, + wantTransit: false, + }, + { + name: "running on finished flips to finished", + currentStatus: GameStatusRunning, + runtimeStatus: "finished", + wantStatus: GameStatusFinished, + wantTransit: true, + }, + { + name: "finished stays finished on finished snapshot", + currentStatus: GameStatusFinished, + runtimeStatus: "finished", + wantStatus: GameStatusFinished, + wantTransit: false, + }, + { + name: "cancelled stays cancelled on finished snapshot", + currentStatus: GameStatusCancelled, + runtimeStatus: "finished", + wantStatus: GameStatusCancelled, + wantTransit: false, + }, + { + name: "paused on stopped snapshot flips to finished", + currentStatus: GameStatusPaused, + runtimeStatus: "stopped", + wantStatus: GameStatusFinished, + wantTransit: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, transit := nextStatusFromSnapshot(tt.currentStatus, RuntimeSnapshot{ + RuntimeStatus: tt.runtimeStatus, + }) + if got != tt.wantStatus { + t.Errorf("status = %q, want %q", got, tt.wantStatus) + } + if transit != tt.wantTransit { + t.Errorf("transit = %v, want %v", transit, tt.wantTransit) + } + }) + } +} diff --git a/backend/internal/notification/catalog.go b/backend/internal/notification/catalog.go index f1b85e1..84666cf 100644 --- a/backend/internal/notification/catalog.go +++ b/backend/internal/notification/catalog.go @@ -18,6 +18,7 @@ const ( KindRuntimeContainerStartFailed = "runtime.container_start_failed" KindRuntimeStartConfigInvalid = "runtime.start_config_invalid" KindGameTurnReady = "game.turn.ready" + KindGamePaused = "game.paused" ) // CatalogEntry describes the per-kind delivery policy: which channels @@ -99,6 +100,9 @@ var catalog = map[string]CatalogEntry{ KindGameTurnReady: { Channels: []string{ChannelPush}, }, + KindGamePaused: { + Channels: []string{ChannelPush}, + }, } // LookupCatalog returns the per-kind policy and a boolean reporting @@ -128,5 +132,6 @@ func SupportedKinds() []string { KindRuntimeContainerStartFailed, KindRuntimeStartConfigInvalid, KindGameTurnReady, + KindGamePaused, } } diff --git a/backend/internal/notification/catalog_test.go b/backend/internal/notification/catalog_test.go index a566bf3..7dbae5c 100644 --- a/backend/internal/notification/catalog_test.go +++ b/backend/internal/notification/catalog_test.go @@ -40,6 +40,7 @@ func TestCatalogChannels(t *testing.T) { KindRuntimeContainerStartFailed: {ChannelEmail}, KindRuntimeStartConfigInvalid: {ChannelEmail}, KindGameTurnReady: {ChannelPush}, + KindGamePaused: {ChannelPush}, } for kind, want := range expect { entry, ok := LookupCatalog(kind) diff --git a/backend/internal/notification/events_test.go b/backend/internal/notification/events_test.go index e8ab482..a69f6af 100644 --- a/backend/internal/notification/events_test.go +++ b/backend/internal/notification/events_test.go @@ -20,8 +20,14 @@ import ( // other consumer reads the payload — adopting the FB encoder would // require a new TS notification stub set and the regen tooling for // `pkg/schema/fbs/notification.fbs` without buying anything. +// +// `game.paused` (Phase 25) follows the same JSON-friendly contract: +// payload is `{game_id, turn, reason}` consumed by the same in-game +// shell layout, so there is no value in dragging a FB schema in for +// one consumer. var jsonFriendlyKinds = map[string]bool{ KindGameTurnReady: true, + KindGamePaused: true, } // TestBuildClientPushEventCoversCatalog asserts that every catalog kind @@ -77,6 +83,11 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) { "game_id": gameID.String(), "turn": int32(7), }}, + {"game paused", KindGamePaused, map[string]any{ + "game_id": gameID.String(), + "turn": int32(7), + "reason": "generation_failed", + }}, } seenKinds := map[string]bool{} diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql index fa7547a..add16b9 100644 --- a/backend/internal/postgres/migrations/00001_init.sql +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -606,7 +606,7 @@ CREATE TABLE notifications ( 'lobby.race_name.expired', 'runtime.image_pull_failed', 'runtime.container_start_failed', 'runtime.start_config_invalid', - 'game.turn.ready' + 'game.turn.ready', 'game.paused' )) ); diff --git a/backend/internal/runtime/errors.go b/backend/internal/runtime/errors.go index f53b64d..bfb228e 100644 --- a/backend/internal/runtime/errors.go +++ b/backend/internal/runtime/errors.go @@ -42,4 +42,23 @@ var ( // ErrShutdown means the runtime service has stopped accepting // work because the parent context was cancelled. ErrShutdown = errors.New("runtime: shutting down") + + // ErrTurnAlreadyClosed reports that the runtime is currently + // producing a turn — runtime status is `generation_in_progress` + // — and the engine is not accepting writes for the closing + // turn. Handlers map this to HTTP 409 with httperr code + // `turn_already_closed`; the UI shows a conflict banner and + // waits for the next `game.turn.ready` push. + ErrTurnAlreadyClosed = errors.New("runtime: turn already closed") + + // ErrGamePaused reports that the game is not in a state that + // accepts user-games commands or orders: the runtime row + // carries `paused = true`, or the runtime status lands on any + // terminal value (`engine_unreachable`, `generation_failed`, + // `stopped`, `finished`, `removed`), or the game has not yet + // finished bootstrapping (`starting`). Handlers map this to + // HTTP 409 with httperr code `game_paused`; the UI surfaces a + // pause banner and waits for an admin resume or a fresh + // snapshot. + ErrGamePaused = errors.New("runtime: game paused") ) diff --git a/backend/internal/runtime/orders_accept_test.go b/backend/internal/runtime/orders_accept_test.go new file mode 100644 index 0000000..7633854 --- /dev/null +++ b/backend/internal/runtime/orders_accept_test.go @@ -0,0 +1,82 @@ +package runtime + +import ( + "errors" + "testing" +) + +// TestOrdersAcceptStatus pins down the Phase 25 pre-check that +// gates the user-games command/order handlers against the runtime +// record. The decision must distinguish a turn cutoff (engine is +// producing) from a paused game so the UI can surface the right +// banner; all other non-running runtime statuses collapse into +// `ErrGamePaused`. +func TestOrdersAcceptStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rec RuntimeRecord + want error + }{ + { + name: "running and not paused accepts orders", + rec: RuntimeRecord{Status: RuntimeStatusRunning, Paused: false}, + want: nil, + }, + { + name: "running but paused returns game paused", + rec: RuntimeRecord{Status: RuntimeStatusRunning, Paused: true}, + want: ErrGamePaused, + }, + { + name: "generation in progress returns turn already closed", + rec: RuntimeRecord{Status: RuntimeStatusGenerationInProgress}, + want: ErrTurnAlreadyClosed, + }, + { + name: "generation failed returns game paused", + rec: RuntimeRecord{Status: RuntimeStatusGenerationFailed}, + want: ErrGamePaused, + }, + { + name: "engine unreachable returns game paused", + rec: RuntimeRecord{Status: RuntimeStatusEngineUnreachable}, + want: ErrGamePaused, + }, + { + name: "stopped returns game paused", + rec: RuntimeRecord{Status: RuntimeStatusStopped}, + want: ErrGamePaused, + }, + { + name: "finished returns game paused", + rec: RuntimeRecord{Status: RuntimeStatusFinished}, + want: ErrGamePaused, + }, + { + name: "removed returns game paused", + rec: RuntimeRecord{Status: RuntimeStatusRemoved}, + want: ErrGamePaused, + }, + { + name: "starting returns game paused", + rec: RuntimeRecord{Status: RuntimeStatusStarting}, + want: ErrGamePaused, + }, + { + name: "paused takes precedence over generation in progress", + rec: RuntimeRecord{Status: RuntimeStatusGenerationInProgress, Paused: true}, + want: ErrGamePaused, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := OrdersAcceptStatus(tt.rec) + if !errors.Is(got, tt.want) { + t.Errorf("OrdersAcceptStatus = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/internal/runtime/scheduler.go b/backend/internal/runtime/scheduler.go index ceccab1..d126ad8 100644 --- a/backend/internal/runtime/scheduler.go +++ b/backend/internal/runtime/scheduler.go @@ -7,6 +7,7 @@ import ( "time" "galaxy/backend/internal/dockerclient" + "galaxy/backend/internal/engineclient" "galaxy/cronutil" "github.com/google/uuid" @@ -213,6 +214,22 @@ func (sch *Scheduler) loop(ctx context.Context, rec RuntimeRecord, done chan str // tick runs one engine /admin/turn call under the per-game mutex, // publishes the resulting snapshot, and clears `skip_next_tick`. +// +// Phase 25 wraps the engine call between two runtime-status flips so +// the backend order handler can reject late submits while the engine +// is producing: +// +// - before `Engine.Turn`: runtime status moves to +// `generation_in_progress`; the loop's running-only guard tolerates +// this because the flip back happens inside the same tick. +// - on success: runtime status moves back to `running` (unless the +// engine reports `finished`, in which case `publishSnapshot` has +// already promoted the row to `finished`). +// - on error: runtime status moves to `generation_failed` (engine +// validation failure) or `engine_unreachable` (transport / 5xx). +// The matching snapshot is forwarded to lobby through +// `publishFailureSnapshot` so lobby can flip the game to `paused` +// and emit `game.paused`. func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error { mu := sch.svc.gameLock(rec.GameID) if !mu.TryLock() { @@ -224,10 +241,24 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error { if err != nil { return err } + if _, err := sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusGenerationInProgress, ""); err != nil { + sch.svc.completeOperation(ctx, op, err) + return err + } state, err := sch.svc.deps.Engine.Turn(ctx, rec.EngineEndpoint) if err != nil { sch.svc.completeOperation(ctx, op, err) - _, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusEngineUnreachable, "") + failureStatus := RuntimeStatusEngineUnreachable + if errors.Is(err, engineclient.ErrEngineValidation) { + failureStatus = RuntimeStatusGenerationFailed + } + _, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, failureStatus, "down") + if pubErr := sch.svc.publishFailureSnapshot(ctx, rec.GameID, failureStatus); pubErr != nil { + sch.svc.deps.Logger.Warn("publish failure snapshot to lobby", + zap.String("game_id", rec.GameID.String()), + zap.String("runtime_status", failureStatus), + zap.Error(pubErr)) + } // On engine unreachable, also clear skip_next_tick so the next // real tick can start fresh. _ = sch.clearSkipFlag(ctx, rec.GameID) @@ -244,6 +275,12 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error { sch.svc.completeOperation(ctx, op, err) return err } + if !state.Finished { + // `publishSnapshot` patches CurrentTurn / EngineHealth but does + // not reset the status column; reopen the orders window here so + // the next loop iteration finds the runtime back in `running`. + _, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusRunning, "ok") + } sch.svc.completeOperation(ctx, op, nil) _ = sch.clearSkipFlag(ctx, rec.GameID) return nil diff --git a/backend/internal/runtime/service.go b/backend/internal/runtime/service.go index 8166dea..d4495e1 100644 --- a/backend/internal/runtime/service.go +++ b/backend/internal/runtime/service.go @@ -257,6 +257,57 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid. return s.deps.Store.LoadPlayerMapping(ctx, gameID, userID) } +// CheckOrdersAccept verifies that the runtime is in a state that +// accepts user-games commands and orders. It is called by the user +// game-proxy handlers (`Commands`, `Orders`) before forwarding to +// engine, so the backend's turn-cutoff and pause guards run before +// network traffic leaves the host. The decision itself lives in the +// pure helper `OrdersAcceptStatus` so it can be unit-tested without +// constructing a full Service. +// +// A missing runtime row is surfaced as `ErrNotFound` so the handler +// keeps its existing 404 behaviour. +func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error { + rec, err := s.GetRuntime(ctx, gameID) + if err != nil { + return err + } + return OrdersAcceptStatus(rec) +} + +// OrdersAcceptStatus inspects a runtime record and returns the +// matching sentinel for the user-games order/command pre-check: +// +// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`. +// The cron-driven `Scheduler.tick` has flipped the row before +// calling the engine. The order window reopens once the tick +// completes successfully. +// +// - `runtime_status ∈ {engine_unreachable, generation_failed, +// stopped, finished, removed, starting}` → `ErrGamePaused`. +// The game is not in a state that accepts writes; the lobby +// state machine has either already flipped the game to +// `paused` / `finished` or is still bootstrapping. +// +// - `runtime.Paused = true` → `ErrGamePaused`. The lobby admin +// paused the game explicitly. +// +// - `runtime_status = running` and `Paused = false` → nil +// (forward). +func OrdersAcceptStatus(rec RuntimeRecord) error { + if rec.Paused { + return ErrGamePaused + } + switch rec.Status { + case RuntimeStatusRunning: + return nil + case RuntimeStatusGenerationInProgress: + return ErrTurnAlreadyClosed + default: + return ErrGamePaused + } +} + // EngineEndpoint returns the engine endpoint URL for gameID. Used by // the user game-proxy handlers. func (s *Service) EngineEndpoint(ctx context.Context, gameID uuid.UUID) (string, error) { @@ -812,6 +863,33 @@ func (s *Service) publishSnapshot(ctx context.Context, gameID uuid.UUID, state r return nil } +// publishFailureSnapshot forwards a runtime-failure observation to +// lobby so the game lifecycle can react (e.g. flipping `running` to +// `paused` on `engine_unreachable` / `generation_failed` per Phase +// 25). The snapshot carries the unchanged `current_turn` because no +// new turn has been produced; lobby uses the turn number to anchor +// the `game.paused` idempotency key. +// +// The call is best-effort: lobby errors are returned to the caller +// (the scheduler tick) so the warn-level logging stays in one place. +// A missing runtime cache entry (e.g. the row was just removed by +// the reconciler) collapses into a silent no-op. +func (s *Service) publishFailureSnapshot(ctx context.Context, gameID uuid.UUID, runtimeStatus string) error { + if s.deps.Lobby == nil { + return nil + } + rec, ok := s.deps.Cache.GetRuntime(gameID) + if !ok { + return nil + } + return s.deps.Lobby.OnRuntimeSnapshot(ctx, gameID, LobbySnapshot{ + CurrentTurn: rec.CurrentTurn, + RuntimeStatus: runtimeStatus, + EngineHealth: "down", + ObservedAt: s.deps.Now().UTC(), + }) +} + // transitionRuntimeStatus updates the status / engine_health columns // and refreshes the cache. func (s *Service) transitionRuntimeStatus(ctx context.Context, gameID uuid.UUID, status, health string) (RuntimeRecord, error) { diff --git a/backend/internal/server/handlers_user_games.go b/backend/internal/server/handlers_user_games.go index 0e57994..0c30077 100644 --- a/backend/internal/server/handlers_user_games.go +++ b/backend/internal/server/handlers_user_games.go @@ -60,6 +60,10 @@ func (h *UserGamesHandlers) Commands() gin.HandlerFunc { return } ctx := c.Request.Context() + if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil { + respondGameProxyError(c, h.logger, "user games commands", ctx, err) + return + } mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID) if err != nil { respondGameProxyError(c, h.logger, "user games commands", ctx, err) @@ -105,6 +109,10 @@ func (h *UserGamesHandlers) Orders() gin.HandlerFunc { return } ctx := c.Request.Context() + if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil { + respondGameProxyError(c, h.logger, "user games orders", ctx, err) + return + } mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID) if err != nil { respondGameProxyError(c, h.logger, "user games orders", ctx, err) @@ -257,6 +265,12 @@ func respondGameProxyError(c *gin.Context, logger *zap.Logger, op string, ctx co switch { case errors.Is(err, runtime.ErrNotFound): httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game") + case errors.Is(err, runtime.ErrTurnAlreadyClosed): + httperr.Abort(c, http.StatusConflict, httperr.CodeTurnAlreadyClosed, + "turn already closed; orders are not accepted while the engine is producing") + case errors.Is(err, runtime.ErrGamePaused): + httperr.Abort(c, http.StatusConflict, httperr.CodeGamePaused, + "game is paused; orders are not accepted until it resumes") case errors.Is(err, runtime.ErrConflict): httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error()) default: diff --git a/backend/internal/server/httperr/httperr.go b/backend/internal/server/httperr/httperr.go index 0f093d1..500be7a 100644 --- a/backend/internal/server/httperr/httperr.go +++ b/backend/internal/server/httperr/httperr.go @@ -23,6 +23,22 @@ const ( CodeMethodNotAllowed = "method_not_allowed" CodeInternalError = "internal_error" CodeServiceUnavailable = "service_unavailable" + + // CodeTurnAlreadyClosed marks a user-games command or order rejection + // caused by the backend's turn-cutoff guard: the request arrived + // after the active turn started generating (runtime status + // `generation_in_progress` / `generation_failed` / `engine_unreachable`) + // and the engine no longer accepts writes for the closing turn. The + // caller is expected to wait for the next `game.turn.ready` push and + // resubmit against the new turn. + CodeTurnAlreadyClosed = "turn_already_closed" + + // CodeGamePaused marks a user-games command or order rejection caused + // by the lobby-side game lifecycle: the game is in `paused`, + // `finished`, or any other status that does not accept writes. The + // caller is expected to wait for the game to resume before + // resubmitting. + CodeGamePaused = "game_paused" ) // Body stores the inner `error` object of the standard envelope. diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 65d09c0..adbe95b 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -2314,9 +2314,10 @@ components: type: string description: | Stable machine-readable failure marker. The closed set is - `not_implemented`, `invalid_request`, `unauthorized`, `not_found`, - `conflict`, `method_not_allowed`, `internal_error`, - `service_unavailable`. + `not_implemented`, `invalid_request`, `unauthorized`, + `forbidden`, `not_found`, `conflict`, `method_not_allowed`, + `internal_error`, `service_unavailable`, + `turn_already_closed`, `game_paused`. enum: - not_implemented - invalid_request @@ -2327,6 +2328,8 @@ components: - method_not_allowed - internal_error - service_unavailable + - turn_already_closed + - game_paused message: type: string description: Human-readable client-safe failure description. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index abc25b3..a0e3598 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -785,9 +785,21 @@ Future scale-out hooks (not in MVP): - **runtime snapshot** — engine-status read materialised into the lobby's denormalised view: `current_turn`, `runtime_status`, `engine_health_summary`, `player_turn_stats`. -- **turn cutoff** — the `running → generation_in_progress` CAS transition - that closes the command window. Commands arriving after the CAS are - rejected. +- **turn cutoff** — the `running → generation_in_progress` runtime-status + flip performed by `backend/internal/runtime/scheduler.go` before each + engine `/admin/turn` call. Commands and orders arriving while the + flag is set are rejected by the user-games handlers with HTTP 409 + `turn_already_closed`. The matching reopening flip + (`generation_in_progress → running`) happens on a successful tick; + a failing tick instead drives the lobby to `paused` and fans out + `game.paused` (FUNCTIONAL.md §6.3, §6.5). +- **auto-pause** — the lobby reaction to a failed runtime snapshot + (`engine_unreachable` / `generation_failed`): the game flips + `running → paused`, the order handlers refuse new submits with + HTTP 409 `game_paused`, and `lobby.publishGamePaused` fans out the + push event. Only an admin `/resume` followed by a successful tick + recovers the game; the UI relies on the next `game.turn.ready` to + clear the paused banner. - **outbox** — the durable queue of pending mail rows in `mail_deliveries`, drained by the mail worker. - **freshness window** — the symmetric ±5-minute interval around server diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index e148b45..2a89153 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -635,18 +635,40 @@ validity and ordering of in-game decisions. Gateway needs to know the typed FB shape only to transcode the wire format; the per-command semantics live in the engine. -### 6.3 Turn cutoff +### 6.3 Turn cutoff and auto-pause A running game continuously alternates between a command-accepting -window and a generation phase. The transition `running → -generation_in_progress` is the cutoff: any command or order that -arrives after the cutoff is rejected by backend before forwarding, -because the engine no longer accepts writes for the closing turn. -After generation finishes, backend re-opens the window for the next -turn. +window and a generation phase, driven by the cron expression stored +in `runtime_records.turn_schedule`. The backend scheduler +(`backend/internal/runtime/scheduler.go`) wraps each engine +`/admin/turn` call between two `runtime_status` flips: + +- Before the engine call: `running → generation_in_progress`. + The user-games command/order handlers + (`backend/internal/server/handlers_user_games.go`) consult the + per-game runtime record on every request and reject with + HTTP 409 + `code = turn_already_closed` while the runtime sits in + `generation_in_progress`. The error envelope mirrors backend's + standard `httperr` shape: `{"error": {"code": + "turn_already_closed", "message": "..."}}`. +- After a successful tick: `generation_in_progress → running`. + The order window re-opens for the new turn and the next + scheduled tick continues normally. +- After a failed tick (`engine_unreachable` / + `generation_failed`): the lobby's `OnRuntimeSnapshot` flips the + game from `running` to `paused` and publishes a `game.paused` + push event (see §6.5). The order handlers reject with HTTP 409 + + `code = game_paused` until an admin resume succeeds. `force-next-turn` (admin) schedules a one-shot extra tick that -advances the next scheduled turn by one cron step. +advances the next scheduled turn by one cron step; the same +status-flip and rejection rules apply. + +Clients distinguish the two rejections by `code`: +`turn_already_closed` means "wait for the next `game.turn.ready` +and resubmit", whereas `game_paused` means "wait for an admin +resume". The web client implements both reactions in +`ui/docs/sync-protocol.md`. ### 6.4 Reports @@ -672,13 +694,24 @@ runtime status, per-player stats). The engine's "game finished" report drives the `running → finished` transition ([Section 3.5](#35-cancellation-and-finish)) and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)). -Among the `game.*` notification kinds, `game.turn.ready` is wired: -`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) -emits one intent per advancing `current_turn`, addressed to every -active membership of the game, with idempotency key -`turn-ready::` and JSON payload `{game_id, turn}`. The -catalog routes the intent through the push channel only; email is -deliberately omitted to avoid per-turn spam. +Among the `game.*` notification kinds, `game.turn.ready` and +`game.paused` are wired: + +- `game.turn.ready` — + `lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) + emits one intent per advancing `current_turn`, addressed to every + active membership of the game, with idempotency key + `turn-ready::` and JSON payload `{game_id, turn}`. +- `game.paused` — the same hook publishes one intent per transition + into `paused` driven by an `engine_unreachable` / + `generation_failed` runtime snapshot, addressed to every active + membership, with idempotency key `paused::` and + JSON payload `{game_id, turn, reason}`. The runtime status that + triggered the transition is carried as `reason` so the UI can + differentiate the copy in a future revision. + +Both kinds route through the push channel only; email is +deliberately omitted to avoid per-turn / per-pause spam. The remaining `game.*` kinds (`game.started`, `game.generation.failed`, `game.finished`) and `mail.dead_lettered` are reserved without a diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index e847210..324ec7e 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -653,17 +653,40 @@ Backend не парсит содержимое payload команд или пр FB-форму только чтобы транскодировать wire-формат; per-command- семантика живёт в движке. -### 6.3 Окно хода +### 6.3 Окно хода и auto-pause Запущенная игра постоянно чередуется между окном приёма команд -и фазой генерации. Переход `running → generation_in_progress` — -cutoff: любая команда или приказ, пришедшие после cutoff, -отклоняются backend до форварда, потому что движок больше не -принимает запись для закрывающегося хода. После окончания -генерации backend заново открывает окно для следующего хода. +и фазой генерации, управляемой cron-выражением из +`runtime_records.turn_schedule`. Backend-планировщик +(`backend/internal/runtime/scheduler.go`) оборачивает каждый +engine `/admin/turn` двумя `runtime_status`-флипами: + +- Перед engine-вызовом: `running → generation_in_progress`. + User-games-handler'ы команд/приказов + (`backend/internal/server/handlers_user_games.go`) на каждом + запросе сверяются с per-game runtime-записью и отклоняют с + HTTP 409 + `code = turn_already_closed`, пока runtime в + `generation_in_progress`. Тело ошибки — стандартный + `httperr`-конверт: `{"error": {"code": "turn_already_closed", + "message": "..."}}`. +- После успешного тика: `generation_in_progress → running`. + Окно приказов открывается на новый ход, следующий тик идёт + как обычно. +- После провалившегося тика (`engine_unreachable` / + `generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру + `running → paused` и публикует push-эвент `game.paused` + (см. §6.5). Order-handler'ы отклоняют запросы с HTTP 409 + + `code = game_paused`, пока админ не выполнит resume. `force-next-turn` (admin) планирует one-shot-доп-тик, который -сдвигает следующий запланированный ход на один cron-шаг. +сдвигает следующий запланированный ход на один cron-шаг; те же +правила status-flip и отклонения применимы. + +Клиенты различают два варианта отказа по `code`: +`turn_already_closed` — «дождись следующего `game.turn.ready` и +отправь ещё раз», `game_paused` — «дождись resume администратором». +Web-клиент реализует оба сценария согласно +`ui/docs/sync-protocol.md`. ### 6.4 Отчёты @@ -690,13 +713,26 @@ status, per-player-stats). Engine-отчёт "game finished" гонит ([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)). -Из `game.*`-видов уведомлений подключён `game.turn.ready`: -`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) -выпускает один intent на каждое увеличение `current_turn`, адресуя -его всем активным membership-ам игры, с idempotency-ключом -`turn-ready::` и JSON-payload-ом `{game_id, turn}`. -Каталог направляет intent только в push-канал; email-фан-аут -сознательно опущен, чтобы избежать спама на каждом ходе. +Из `game.*`-видов уведомлений подключены `game.turn.ready` и +`game.paused`: + +- `game.turn.ready` — + `lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) + выпускает один intent на каждое увеличение `current_turn`, + адресуя его всем активным membership-ам игры, с + idempotency-ключом `turn-ready::` и + JSON-payload-ом `{game_id, turn}`. +- `game.paused` — тот же хук публикует один intent на каждое + выставление статуса `paused` по runtime-снапшоту + (`engine_unreachable` / `generation_failed`), адресуя его всем + активным membership-ам игры, с idempotency-ключом + `paused::` и JSON-payload-ом + `{game_id, turn, reason}`. `reason` несёт runtime-статус, + спровоцировавший переход, чтобы UI смог в будущем + дифференцировать копию. + +Оба вида направляются только в push-канал; email-фан-аут +сознательно опущен, чтобы избежать спама на каждом ходе/паузе. Остальные `game.*`-виды (`game.started`, `game.generation.failed`, `game.finished`) и `mail.dead_lettered` зарезервированы без поставщика; diff --git a/gateway/README.md b/gateway/README.md index 2146820..b357877 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -385,6 +385,12 @@ The current direct `Gateway -> User` self-service boundary uses that pattern: - business error projection: - gateway `result_code` - FlatBuffers error payload mirroring User Service `code` and `message` + - User Service `code` values pass through verbatim as `result_code` + via `projectUserBackendError`; known non-`ok` codes that clients + branch on include `turn_already_closed` (Phase 25 turn cutoff, + HTTP 409 from `Orders` / `Commands` while the runtime is in + `generation_in_progress`) and `game_paused` (Phase 25 auto-pause, + HTTP 409 while the game is in `paused` / `finished` / `removed`). The request envelope version literal is `v1`. `payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`. diff --git a/ui/PLAN.md b/ui/PLAN.md index 9591936..32baf74 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -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::`, 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 diff --git a/ui/docs/order-composer.md b/ui/docs/order-composer.md index 3aa9604..c2df44d 100644 --- a/ui/docs/order-composer.md +++ b/ui/docs/order-composer.md @@ -36,11 +36,18 @@ entry by `cmdId`. Successfully applied entries stay visible in the draft (the player keeps composing until turn cutoff); rejected entries stay until the player edits or removes them. -Phase 25 is reserved for one extension on top of this: per-line -sequencing if a future use case needs to submit commands -individually rather than in one batch. The wire shape is already -flexible enough — the response carries an array of results — so -Phase 25 only changes the client-side iteration policy. +Phase 25 layers a transport-level policy on top of this baseline +without changing the batch semantics. The submit pipeline now +goes through `OrderQueue` (see +[`sync-protocol.md`](sync-protocol.md)): the queue holds the +submit while the browser is offline, classifies +`turn_already_closed` and `game_paused` server replies into +matching banners on the order tab, and exits the loop on the +sticky states so a stream of mutations does not re-elicit the +same gateway reply. Recovery from a `conflict` or `paused` +banner happens on the next `game.turn.ready` push frame via +`OrderDraftStore.resetForNewTurn`, which clears the local draft +and re-hydrates from the server for the new turn. ## Local-validation invariant @@ -63,8 +70,10 @@ submit pipeline filters the draft to `valid` entries only — any ```text draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied - ╲ │ ╲ - ╲──validate──▶ invalid ╲──nack──▶ rejected + ╲ │ │ ╲ + ╲──validate──▶ invalid │ ╲──nack──▶ rejected + │ + ╲────turn_already_closed──▶ conflict ``` Transitions: @@ -76,6 +85,14 @@ Transitions: the draft and sends it to the gateway. - **`submitting → applied` / `submitting → rejected`**: the gateway responded; the entry is no longer in flight. +- **`submitting → conflict`** (Phase 25): the gateway returned + `resultCode = "turn_already_closed"`. The order tab surfaces a + banner above the command list. Any subsequent mutation + re-validates the conflict row back to `valid` / `invalid`; a + matching `game.turn.ready` push frame triggers + `resetForNewTurn`, which wipes the draft entirely. See + [`sync-protocol.md`](sync-protocol.md) for the full state + table and recovery paths. Phase 14 lands the local validators (`draft → valid | invalid`), the submit pipeline (`valid → submitting → applied | rejected`), diff --git a/ui/docs/sync-protocol.md b/ui/docs/sync-protocol.md new file mode 100644 index 0000000..a1fc22e --- /dev/null +++ b/ui/docs/sync-protocol.md @@ -0,0 +1,217 @@ +# UI sync protocol + +Phase 25 wires the transport-level policy that keeps the local +order draft consistent with the gateway across two failure modes +that Phase 14 punted on: transient network outages and turn +cutoffs the player did not anticipate. The wiring also reacts to +admin-initiated game pauses signalled by the new `game.paused` +push event. + +The contract lives at three layers: + +- **Backend** owns the turn-cutoff guard and the auto-pause on + generation failure (`backend/internal/runtime/scheduler.go`, + `backend/internal/lobby/runtime_hooks.go`, + `backend/internal/server/handlers_user_games.go`); see + `docs/FUNCTIONAL.md §6.3` for the user-visible spec. +- **Gateway** translates backend's `httperr` envelope into the + `ExecuteCommandResponse` envelope without re-interpreting the + code; `turn_already_closed` and `game_paused` surface as + `resultCode` values verbatim. +- **UI** detects those codes in + [`src/sync/order-queue.svelte.ts`](../frontend/src/sync/order-queue.svelte.ts) + and projects them onto the + [`OrderDraftStore`](../frontend/src/sync/order-draft.svelte.ts) + state machine consumed by + [`order-tab.svelte`](../frontend/src/lib/sidebar/order-tab.svelte). + +This document covers the UI side of the protocol — send-loop +semantics, retry budgets, conflict UX, paused UX, and the +recovery paths back to a normal `synced` state. + +## Send-loop semantics + +The order draft store no longer calls `submitOrder` directly. Every +auto-sync attempt goes through `OrderQueue.send(submitFn)`, which +acts as a thin policy gate: + +```text +mutation ──▶ scheduleSync ──▶ runSync ──▶ queue.send(submitFn) + │ + ▼ + classify outcome: + - success + - rejected + - conflict + - paused + - offline + - failed +``` + +The classification is dispatched into the store: + +| outcome | command status flip | syncStatus | banner side-effect | +| ----------- | ---------------------------------- | ---------- | ----------------------------------------- | +| `success` | per-command `applied` / `rejected` | `synced` | none | +| `rejected` | submitting → `rejected` | `error` | none (the row colour is enough) | +| `conflict` | submitting → `conflict` | `conflict` | `conflictBanner = { turn, code, message }` | +| `paused` | submitting → `valid` | `paused` | `pausedBanner = { reason, code, message }` | +| `offline` | submitting → `valid` | `offline` | none — the status bar carries the copy | +| `failed` | submitting → `valid` | `error` | `syncError = reason` | + +Only one submit is in flight at a time. Mutations made during a +flight set `pending = true` so the loop runs one more iteration +with a fresh snapshot once the active call settles. + +## Offline detection and retry budget + +`OrderQueue.start` subscribes to the browser's `online` / `offline` +events and primes `OrderQueue.online` from `navigator.onLine`. The +queue intentionally treats offline as a fast-fail: + +- A `send()` invocation with `online === false` returns + `{kind: "offline"}` without invoking `submitFn`. The draft store + reverts every submitting row back to `valid` and parks the loop + with `syncStatus = "offline"`. No further sends fire until the + browser re-emits `online`. +- When the browser flips back to `online`, the queue invokes the + `onOnline` callback supplied at `start()`. The draft store wires + this callback to `scheduleSync()` — exactly one new attempt per + online flip. The store's existing single-slot pending machinery + takes care of the rest (further mutations during that attempt + coalesce into one follow-up). + +There is no inline retry inside `OrderQueue.send`. The +plan's "retry once on reconnect" budget is therefore literal: + +- offline ⇒ hold (no attempt) +- next `online` event ⇒ one attempt +- attempt succeeds ⇒ `syncStatus = "synced"` +- attempt throws ⇒ `syncStatus = "error"` and the existing + manual-retry affordance in the order tab applies + +A throw mid-flight while `navigator.onLine` is still `true` is +treated as a regular failure (`failed` outcome). A throw whose +`onlineProbe` check returns `false` collapses into `offline`, so +flaky connectivity does not get classified as a hard error. + +## Conflict UX — `turn_already_closed` + +When the gateway returns `resultCode = "turn_already_closed"` (or +the per-error-body `code` matches it), the queue emits a +`conflict` outcome. The store: + +1. Marks every command that was in `submitting` as `conflict` — + the matching row shows the new badge. +2. Records `conflictBanner = { turn, code, message }`. `turn` is + read from the `getCurrentTurn` callback the layout supplied at + `bindClient` (the turn the player was composing for); it may be + `null` in tests that omit the callback, in which case the + banner falls back to a turn-less template. +3. Sets `syncStatus = "conflict"`. The loop exits without firing + the `pending` follow-up — `scheduleSync` short-circuits while + the store is in conflict, so a flurry of keystrokes does not + re-elicit the same gateway reply. + +The banner clears on either of two signals: + +- **Any local mutation** (`add`, `remove`, `move`) calls + `clearConflictForMutation`, which drops the banner and + re-validates every `conflict`-marked command back through the + local validator. The mutation then auto-syncs as usual — likely + a fresh attempt against the new turn, often resulting in + `success`. +- **A `game.turn.ready` push** arriving while the store is in + `conflict` triggers `resetForNewTurn`. The local draft is + wiped, `hydrateFromServer` pulls the new turn's empty order, + and the banner clears. Old commands for the prior turn become + history (read-only) and live on the server's `user.games.order` + for `?turn=N`. + +## Paused UX — `game_paused` / `game.paused` + +The paused-banner has two entry points: + +- **`Orders` handler reply with `code = "game_paused"`** — the + player attempted to submit while the game was paused. The + queue emits a `paused` outcome; the store reverts submitting + rows to `valid`, records `pausedBanner = { reason, code, message }`, + and locks `syncStatus = "paused"`. +- **`game.paused` push frame** — lobby published the event when + it flipped the game to `paused` (see backend §6.5). The layout + subscribes via `eventStream.on("game.paused", ...)` and calls + `orderDraft.markPaused({ reason })`, which arrives at the same + state via a different path. + +While in `paused` the auto-sync loop refuses to fire (the +`scheduleSync` early-exit). The store's `hydrateFromServer` also +short-circuits if `syncStatus === "paused"` to avoid clobbering +the banner with a fresh `synced` flip. + +Recovery is the same as conflict: a `game.turn.ready` push +clears the pause via `resetForNewTurn`. The matching admin flow +on the backend is an explicit `/resume` followed by a successful +scheduler tick that emits the next turn. + +## Recovery via `resetForNewTurn` + +`resetForNewTurn` is the single entry point that wipes both +banners and rebuilds the draft against a fresh turn: + +```ts +async resetForNewTurn(opts: { client; turn }) { + this.commands = []; + this.statuses = {}; + this.updatedAt = 0; + this.conflictBanner = null; + this.pausedBanner = null; + this.syncStatus = "idle"; + this.syncError = null; + await this.persist(); + await this.hydrateFromServer({ client, turn }); +} +``` + +The layout calls it from the `game.turn.ready` subscription +whenever the prior `syncStatus` was either `conflict` or +`paused`. A regular turn advance (no banner active) keeps the +existing behaviour: `markPendingTurn` shows the toast and the +player chooses when to navigate; the local draft survives the +transition unchanged. + +## Test surface + +- **Vitest unit**: + [`tests/order-queue.test.ts`](../frontend/tests/order-queue.test.ts) + covers the queue's classification + online/offline plumbing in + isolation; + [`tests/order-draft.test.ts`](../frontend/tests/order-draft.test.ts) + covers the store's reaction to each outcome and the + `resetForNewTurn` / `markPaused` paths; + [`tests/order-tab.test.ts`](../frontend/tests/order-tab.test.ts) + asserts the banner DOM; + [`tests/events.test.ts`](../frontend/tests/events.test.ts) + pins the `game.paused` dispatch. +- **Playwright e2e**: + [`tests/e2e/order-sync.spec.ts`](../frontend/tests/e2e/order-sync.spec.ts) + drives the order tab through the conflict and paused-push + scenarios with mocked gateway and signed-event frames. + +The offline-online round-trip is covered at the Vitest level +because Playwright's `context.setOffline(true)` is a coarse +network mute that conflicts with the dev-server bootstrap and +the in-test fixture key wiring; the store-level test uses +injected `onlineProbe` / `addEventListener` to drive the same +state machine deterministically. + +## Known follow-ups + +- Admin resume currently produces a `running → paused → running` + status flip on the lobby side without an explicit push event. + The UI relies on the next `game.turn.ready` for recovery; a + dedicated `game.resumed` event would let the banner clear + immediately without waiting for the next cron tick. Not part + of this phase. +- The conflict banner shows the player-facing template + unmodified; a future revision may interpolate the explicit + cutoff timestamp once the server adds it to the error body. diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 2438499..a9b1d9e 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -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}", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index eaa75c5..2f10250 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -129,13 +129,20 @@ const ru: Record = { "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}", diff --git a/ui/frontend/src/lib/sidebar/order-tab.svelte b/ui/frontend/src/lib/sidebar/order-tab.svelte index 673910b..7daf940 100644 --- a/ui/frontend/src/lib/sidebar/order-tab.svelte +++ b/ui/frontend/src/lib/sidebar/order-tab.svelte @@ -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`

{i18n.t("game.sidebar.tab.order")}

+ {#if draft !== undefined && draft.pausedBanner !== null} + + {/if} + {#if draft !== undefined && draft.conflictBanner !== null} + + {/if} {#if draft === undefined || draft.commands.length === 0}

{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; diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte index 46afa83..97ef21f 100644 --- a/ui/frontend/src/routes/games/[id]/+layout.svelte +++ b/ui/frontend/src/routes/games/[id]/+layout.svelte @@ -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; + 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(); diff --git a/ui/frontend/src/sync/order-draft.svelte.ts b/ui/frontend/src/sync/order-draft.svelte.ts index bfaab5f..baae0ee 100644 --- a/ui/frontend/src/sync/order-draft.svelte.ts +++ b/ui/frontend/src/sync/order-draft.svelte.ts @@ -24,6 +24,7 @@ import type { Cache } from "../platform/store/index"; import type { GalaxyClient } from "../api/galaxy-client"; import { fetchOrder } from "./order-load"; +import { OrderQueue, type OrderQueueStartOptions } from "./order-queue.svelte"; import { isRelation, isShipGroupCargo, @@ -49,7 +50,47 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft"); type Status = "idle" | "ready" | "error"; -export type SyncStatus = "idle" | "syncing" | "synced" | "error"; +/** + * SyncStatus is the order-tab status-bar projection of the auto-sync + * pipeline. Phase 14 introduced the `idle`/`syncing`/`synced`/`error` + * triplet; Phase 25 adds `offline` (queued during a network outage, + * will retry on reconnect), `conflict` (server told us the turn was + * already closed; banner pending), and `paused` (game in pause; no + * submits until it resumes). + */ +export type SyncStatus = + | "idle" + | "syncing" + | "synced" + | "error" + | "offline" + | "conflict" + | "paused"; + +/** + * ConflictBanner is the optimistic-conflict UX state displayed + * above the order list when a submit landed after the turn cutoff. + * `turn` is the value the player thought was open at submit time; + * it is read from the `getCurrentTurn` callback supplied to + * `bindClient`. The banner is cleared by `resetForNewTurn` (next + * `game.turn.ready`) or by any local mutation. + */ +export interface ConflictBanner { + turn: number | null; + code: string; + message: string; +} + +/** + * PausedBanner is displayed when the server tells us the game is + * paused. The banner is cleared by `resetForNewTurn` once the game + * resumes (a fresh `game.turn.ready` event). + */ +export interface PausedBanner { + code: string; + message: string; + reason: string; +} export class OrderDraftStore { commands: OrderCommand[] = $state([]); @@ -61,24 +102,52 @@ export class OrderDraftStore { /** * syncStatus reflects the auto-sync pipeline state for the order * tab status bar: - * - `idle` — no sync attempted yet (e.g. fresh draft after - * hydration or before the first mutation). - * - `syncing` — a `submitOrder` call is in flight. - * - `synced` — the last sync succeeded; statuses match the - * server's view. - * - `error` — the last sync failed (network or non-`ok`); the - * next mutation triggers a retry, or the user can - * force a re-sync via `forceSync`. + * - `idle` — no sync attempted yet (e.g. fresh draft after + * hydration or before the first mutation). + * - `syncing` — a `submitOrder` call is in flight. + * - `synced` — the last sync succeeded; statuses match the + * server's view. + * - `error` — the last sync failed (network or non-`ok`); the + * next mutation triggers a retry, or the user can + * force a re-sync via `forceSync`. + * - `offline` — the browser is offline; the last submit was + * held. A fresh send fires on the next `online` + * flip via the queue callback. + * - `conflict` — the gateway returned `turn_already_closed`; + * the in-flight commands are marked `conflict` + * and `conflictBanner` carries the user-facing + * copy. + * - `paused` — the gateway returned `game_paused` (or a + * `game.paused` push frame arrived); no submits + * fire until `resetForNewTurn` clears it. */ syncStatus: SyncStatus = $state("idle"); syncError: string | null = $state(null); + /** + * conflictBanner is non-null whenever `syncStatus === "conflict"`. + * The order tab renders the banner above the command list with + * the turn number interpolated; clearing it is the + * `resetForNewTurn` / mutation responsibility. + */ + conflictBanner: ConflictBanner | null = $state(null); + + /** + * pausedBanner is non-null whenever `syncStatus === "paused"`. + * The order tab renders a pause-specific banner separate from + * the conflict path. + */ + pausedBanner: PausedBanner | null = $state(null); + private cache: Cache | null = null; private gameId = ""; private destroyed = false; private client: GalaxyClient | null = null; private syncing: Promise | null = null; private pending = false; + private queue = new OrderQueue(); + private queueStarted = false; + private getCurrentTurn: (() => number) | null = null; /** * init loads the persisted draft for `opts.gameId` from `opts.cache` @@ -93,7 +162,9 @@ export class OrderDraftStore { * authoritative read that always overwrites the local cache when * the server has a stored order. */ - async init(opts: { cache: Cache; gameId: string }): Promise { + async init( + opts: { cache: Cache; gameId: string; queue?: OrderQueueStartOptions }, + ): Promise { this.cache = opts.cache; this.gameId = opts.gameId; try { @@ -110,6 +181,7 @@ export class OrderDraftStore { this.status = "error"; this.error = err instanceof Error ? err.message : "load failed"; } + this.startQueue(opts.queue); } /** @@ -118,9 +190,18 @@ export class OrderDraftStore { * this after the boot `Promise.all` resolves and before * `hydrateFromServer`, so any mutation that lands afterwards goes * through the network. + * + * Phase 25: `opts.getCurrentTurn` lets the conflict banner + * interpolate the turn number the player was composing for. The + * layout passes `() => gameState.currentTurn`; tests may omit it, + * in which case the banner falls back to a turn-less template. */ - bindClient(client: GalaxyClient): void { + bindClient( + client: GalaxyClient, + opts: { getCurrentTurn?: () => number } = {}, + ): void { this.client = client; + this.getCurrentTurn = opts.getCurrentTurn ?? null; } /** @@ -138,6 +219,14 @@ export class OrderDraftStore { turn: number; }): Promise { if (this.status !== "ready") return; + // Phase 25: a `game.paused` push frame may arrive before the + // initial hydrate completes (the layout subscribes early to + // avoid losing in-flight frames). The pause is stickier than a + // freshly-loaded snapshot — keep the banner up and skip the + // fetch entirely. A subsequent `resetForNewTurn` (triggered by + // `game.turn.ready` after the game resumes) re-runs the + // hydration from scratch. + if (this.syncStatus === "paused") return; this.client = opts.client; // Guard against placeholder game ids the Phase 10 e2e specs // still use — auto-sync needs a real UUID for the FBS request @@ -152,6 +241,11 @@ export class OrderDraftStore { try { const fetched = await fetchOrder(opts.client, this.gameId, opts.turn); if (this.destroyed) return; + // If `markPaused` landed between the initial syncStatus + // flip and the awaited fetch, the pause is the + // authoritative state — do not overwrite it with synced. + // The fetched commands are still adopted so a later + // `resetForNewTurn` can build on top of them. this.commands = fetched.commands; this.updatedAt = fetched.updatedAt; this.recomputeStatuses(); @@ -166,11 +260,15 @@ export class OrderDraftStore { } this.statuses = next; await this.persist(); - this.syncStatus = "synced"; + if ((this.syncStatus as SyncStatus) !== "paused") { + this.syncStatus = "synced"; + } } catch (err) { if (this.destroyed) return; - this.syncStatus = "error"; - this.syncError = err instanceof Error ? err.message : "fetch failed"; + if ((this.syncStatus as SyncStatus) !== "paused") { + this.syncStatus = "error"; + this.syncError = err instanceof Error ? err.message : "fetch failed"; + } console.warn("order-draft: server hydration failed", err); } } @@ -207,6 +305,7 @@ export class OrderDraftStore { */ async add(command: OrderCommand): Promise { if (this.status !== "ready") return; + this.clearConflictForMutation(); const removed: string[] = []; let nextCommands: OrderCommand[]; if (command.kind === "setProductionType") { @@ -288,6 +387,7 @@ export class OrderDraftStore { if (this.status !== "ready") return; const next = this.commands.filter((cmd) => cmd.id !== id); if (next.length === this.commands.length) return; + this.clearConflictForMutation(); this.commands = next; const nextStatuses = { ...this.statuses }; delete nextStatuses[id]; @@ -310,6 +410,7 @@ export class OrderDraftStore { if (fromIndex < 0 || fromIndex >= length) return; if (toIndex < 0 || toIndex >= length) return; if (fromIndex === toIndex) return; + this.clearConflictForMutation(); const next = [...this.commands]; const [picked] = next.splice(fromIndex, 1); if (picked === undefined) return; @@ -327,10 +428,61 @@ export class OrderDraftStore { this.scheduleSync(); } + /** + * markPaused projects an incoming `game.paused` push event into + * the store: the order tab shows the pause banner, the auto-sync + * loop short-circuits, and any submitting rows revert to `valid` + * (the matching engine state is still the old one). The layout + * calls this from the `game.paused` subscription. `reason` + * carries the raw runtime status published by lobby + * (`engine_unreachable` / `generation_failed`); the UI ignores + * it today but the payload is preserved for future copy + * differentiation. + */ + markPaused(opts: { reason: string; message?: string }): void { + if (this.status !== "ready") return; + this.revertSubmittingToValidInternal(); + this.pausedBanner = { + code: "game_paused", + message: opts.message ?? "Game paused. Orders are not accepted until it resumes.", + reason: opts.reason, + }; + this.syncStatus = "paused"; + this.syncError = null; + } + + /** + * resetForNewTurn drops the local draft, clears every Phase 25 + * banner, and hydrates from the server for the supplied turn. + * The layout calls this from the `game.turn.ready` subscription + * when the prior `syncStatus` was `conflict` or `paused`. The + * effect mirrors a fresh boot: cache wipe → fetch → seed. + */ + async resetForNewTurn(opts: { + client: GalaxyClient; + turn: number; + }): Promise { + if (this.status !== "ready") return; + this.commands = []; + this.statuses = {}; + this.updatedAt = 0; + this.conflictBanner = null; + this.pausedBanner = null; + this.syncStatus = "idle"; + this.syncError = null; + await this.persist(); + await this.hydrateFromServer({ client: opts.client, turn: opts.turn }); + } + dispose(): void { this.destroyed = true; this.cache = null; this.client = null; + this.getCurrentTurn = null; + if (this.queueStarted) { + this.queue.stop(); + this.queueStarted = false; + } } private scheduleSync(): void { @@ -338,6 +490,16 @@ export class OrderDraftStore { // Same UUID guard as `hydrateFromServer` — placeholder game // ids in test fixtures must not blow up the auto-sync path. if (!isUuid(this.gameId)) return; + // Conflict / paused states are sticky: the order tab is + // waiting for the next `game.turn.ready` (conflict) or for + // the admin to resume (paused). Local mutations clear the + // conflict; the layout's `markPaused`/`resetForNewTurn` clear + // the pause. Trying to send mid-state would re-elicit the + // same gateway reply on every keystroke and overwrite the + // banner with the same message. + if (this.syncStatus === "conflict" || this.syncStatus === "paused") { + return; + } if (this.syncing !== null) { this.pending = true; return; @@ -378,45 +540,98 @@ export class OrderDraftStore { this.syncStatus = "syncing"; this.syncError = null; - try { - const result = await submitOrder( - client, - this.gameId, - submittable, - { updatedAt: this.updatedAt }, - ); - if (this.destroyed) return; - if (result.ok) { - this.applyResultsInternal(result.results, result.updatedAt); + const outcome = await this.queue.send(() => + submitOrder(client, this.gameId, submittable, { + updatedAt: this.updatedAt, + }), + ); + if (this.destroyed) return; + switch (outcome.kind) { + case "success": { + this.applyResultsInternal( + outcome.result.results, + outcome.result.updatedAt, + ); // Even with `result.ok === true` an individual // command may have been rejected by the engine // (e.g. validation passed transcoders but failed // the in-game rule). Surface that as an error in // the sync bar so the player notices and can fix // or remove the offending command. - const anyRejected = Array.from(result.results.values()).some( - (s) => s === "rejected", - ); + const anyRejected = Array.from( + outcome.result.results.values(), + ).some((s) => s === "rejected"); this.syncStatus = anyRejected ? "error" : "synced"; this.syncError = anyRejected ? "engine rejected one or more commands" : null; - } else { + break; + } + case "rejected": { this.markRejectedInternal(submittingIds); this.syncStatus = "error"; - this.syncError = result.message; + this.syncError = outcome.failure.message; + break; + } + case "conflict": { + this.markConflictInternal(submittingIds); + this.conflictBanner = { + turn: this.getCurrentTurn?.() ?? null, + code: outcome.code, + message: outcome.message, + }; + this.syncStatus = "conflict"; + this.syncError = null; + // Stickiness: conflict overrides any pending + // mutations until the next `game.turn.ready` or a + // local edit clears the banner. + return; + } + case "paused": { + this.revertSubmittingToValidInternal(); + this.pausedBanner = { + code: outcome.code, + message: outcome.message, + reason: outcome.code, + }; + this.syncStatus = "paused"; + this.syncError = null; + return; + } + case "offline": { + this.revertSubmittingToValidInternal(); + this.syncStatus = "offline"; + this.syncError = null; + return; + } + case "failed": { + this.revertSubmittingToValidInternal(); + this.syncStatus = "error"; + this.syncError = outcome.reason; + break; } - } catch (err) { - if (this.destroyed) return; - this.revertSubmittingToValidInternal(); - this.syncStatus = "error"; - this.syncError = err instanceof Error ? err.message : "sync failed"; } if (!this.pending) return; } } + private startQueue(opts?: OrderQueueStartOptions): void { + if (this.queueStarted) return; + this.queue.start({ + onOnline: () => { + if (this.destroyed) return; + if (this.syncStatus === "offline") { + this.scheduleSync(); + } + }, + onlineProbe: opts?.onlineProbe, + addEventListener: opts?.addEventListener, + removeEventListener: opts?.removeEventListener, + }); + this.queueStarted = true; + } + private markSubmittingInternal(ids: string[]): void { const next = { ...this.statuses }; for (const id of ids) { @@ -457,6 +672,43 @@ export class OrderDraftStore { this.statuses = next; } + private markConflictInternal(ids: string[]): void { + const next = { ...this.statuses }; + for (const id of ids) { + next[id] = "conflict"; + } + this.statuses = next; + } + + /** + * clearConflictForMutation drops the conflict banner and + * re-validates every `conflict`-marked command back to its + * pre-submit status. Called from every mutation (`add`, + * `remove`, `move`) so the user-driven "Edit and resubmit" flow + * works without an extra dismiss step. + */ + private clearConflictForMutation(): void { + if (this.syncStatus !== "conflict" && this.conflictBanner === null) { + return; + } + const next = { ...this.statuses }; + let mutated = false; + for (const cmd of this.commands) { + if (next[cmd.id] === "conflict") { + next[cmd.id] = validateCommand(cmd); + mutated = true; + } + } + if (mutated) { + this.statuses = next; + } + this.conflictBanner = null; + if (this.syncStatus === "conflict") { + this.syncStatus = "idle"; + this.syncError = null; + } + } + private revertSubmittingToValidInternal(): void { const next = { ...this.statuses }; for (const cmd of this.commands) { diff --git a/ui/frontend/src/sync/order-queue.svelte.ts b/ui/frontend/src/sync/order-queue.svelte.ts new file mode 100644 index 0000000..fa50a32 --- /dev/null +++ b/ui/frontend/src/sync/order-queue.svelte.ts @@ -0,0 +1,231 @@ +// Wraps the order submit pipeline (`sync/submit.ts`) with the Phase +// 25 transport semantics: +// +// - **offline detection** via `navigator.onLine` and the browser +// `online` / `offline` events. While offline, `send()` returns an +// `offline` outcome immediately and the caller is expected to +// leave the in-flight commands in their pre-submit state. +// - **single retry on reconnect** is realised at the consumer +// level: when the browser fires `online`, the queue invokes the +// `onOnline` callback the consumer supplied at `start()`. The +// consumer (`OrderDraftStore`) decides whether to schedule a +// fresh `runSync()` — that single attempt is the retry budget. +// - **conflict / paused classification**: a non-`ok` SubmitResult +// whose `resultCode` or `code` is `turn_already_closed` becomes +// a `conflict` outcome; `game_paused` becomes a `paused` +// outcome. Any other non-`ok` reply stays a `rejected` outcome +// and the consumer keeps the existing per-command behaviour. +// +// The class is dependency-injected so Vitest can drive the +// `online` / `offline` listeners without touching the JSDOM +// globals; production code falls back to `window`/`navigator`. + +import type { SubmitFailure, SubmitResult, SubmitSuccess } from "./submit"; + +/** + * QueueOutcome is the discriminated union the draft store consumes + * after asking the queue to submit a snapshot. Each variant tells + * the consumer exactly which side-effect to apply to the + * per-command statuses and the banner state. + */ +export type QueueOutcome = + | { kind: "success"; result: SubmitSuccess } + | { kind: "rejected"; failure: SubmitFailure } + | { kind: "conflict"; code: string; message: string } + | { kind: "paused"; code: string; message: string } + | { kind: "offline" } + | { kind: "failed"; reason: string }; + +/** + * OrderQueueStartOptions carries the live primitives the queue + * cannot resolve on its own. Tests inject deterministic stubs; + * production passes `undefined` for everything except `onOnline`. + */ +export interface OrderQueueStartOptions { + /** + * onOnline is invoked when the browser flips from offline to + * online (or when `start()` is called while already online and + * the consumer wants an opportunistic flush). The consumer + * decides whether a fresh `send()` is appropriate. + */ + onOnline: () => void; + + /** + * onlineProbe returns the current online state. Defaults to + * `navigator.onLine`; tests inject a closure over a mutable flag. + */ + onlineProbe?: () => boolean; + + /** + * addEventListener / removeEventListener are the hooks the queue + * uses to subscribe to the global `online` / `offline` events. + * Defaults to `window.addEventListener` / `window.removeEventListener`; + * tests inject manual emitters. + */ + addEventListener?: (event: string, handler: () => void) => void; + removeEventListener?: (event: string, handler: () => void) => void; +} + +const CODE_TURN_ALREADY_CLOSED = "turn_already_closed"; +const CODE_GAME_PAUSED = "game_paused"; + +/** + * OrderQueue holds the transport-side policy for the order draft + * store. One instance per draft store; lifecycle is bound to the + * store's `init` / `dispose`. + */ +export class OrderQueue { + /** + * online mirrors the latest browser online signal. Tests assert + * on this rune to drive their state machine; production code + * uses it via the draft store's `syncStatus` projection. + */ + online: boolean = $state(true); + + private onlineProbe: () => boolean = defaultOnlineProbe; + private addEventListener: (event: string, handler: () => void) => void = defaultAddEventListener; + private removeEventListener: (event: string, handler: () => void) => void = defaultRemoveEventListener; + private onOnlineCallback: (() => void) | null = null; + private handleOnline: (() => void) | null = null; + private handleOffline: (() => void) | null = null; + + /** + * start subscribes to the browser online/offline events and + * primes `online` from the current probe value. Calling start a + * second time without `stop()` between them is a no-op so the + * draft store's `init` stays idempotent under double mount. + */ + start(opts: OrderQueueStartOptions): void { + if (this.onOnlineCallback !== null) return; + this.onOnlineCallback = opts.onOnline; + if (opts.onlineProbe !== undefined) { + this.onlineProbe = opts.onlineProbe; + } + if (opts.addEventListener !== undefined) { + this.addEventListener = opts.addEventListener; + } + if (opts.removeEventListener !== undefined) { + this.removeEventListener = opts.removeEventListener; + } + this.online = this.onlineProbe(); + this.handleOnline = () => { + this.online = true; + this.onOnlineCallback?.(); + }; + this.handleOffline = () => { + this.online = false; + }; + this.addEventListener("online", this.handleOnline); + this.addEventListener("offline", this.handleOffline); + } + + /** + * stop unsubscribes from the browser events and forgets the + * consumer callback. Subsequent `send()` calls still classify + * an injected `SubmitResult` correctly, but no online flips will + * be propagated until `start()` runs again. + */ + stop(): void { + if (this.handleOnline !== null) { + this.removeEventListener("online", this.handleOnline); + this.handleOnline = null; + } + if (this.handleOffline !== null) { + this.removeEventListener("offline", this.handleOffline); + this.handleOffline = null; + } + this.onOnlineCallback = null; + } + + /** + * send drives one submit attempt: + * + * - If the queue is currently offline, returns `{kind:"offline"}` + * without invoking submitFn. The consumer is expected to + * leave the in-flight commands in their pre-submit state and + * wait for the `onOnline` callback. + * - Otherwise invokes submitFn. Any throw is reclassified: + * a fresh `onlineProbe()` returning false collapses into + * `offline`; otherwise the throw becomes `failed`. + * - A successful `SubmitResult` is classified into `success`, + * `rejected`, `conflict`, or `paused` depending on the + * non-`ok` `resultCode` / `code` fields. + * + * The queue intentionally does NOT retry inline. The plan's + * "retry once on reconnect" budget is realised by the consumer + * (the draft store) hooking the `onOnline` callback to + * `scheduleSync()` — at most one fresh `send()` per online flip. + */ + async send(submitFn: () => Promise): Promise { + if (!this.online) { + return { kind: "offline" }; + } + let result: SubmitResult; + try { + result = await submitFn(); + } catch (err) { + if (!this.onlineProbe()) { + this.online = false; + return { kind: "offline" }; + } + return { kind: "failed", reason: errorMessage(err) }; + } + return classifyResult(result); + } +} + +/** + * classifyResult maps a `SubmitResult` onto the queue outcome the + * draft store consumes. Exported for unit-tests; the inline path + * uses it through `OrderQueue.send`. + */ +export function classifyResult(result: SubmitResult): QueueOutcome { + if (result.ok) { + return { kind: "success", result }; + } + const code = pickCode(result); + if (code === CODE_TURN_ALREADY_CLOSED) { + return { kind: "conflict", code, message: result.message }; + } + if (code === CODE_GAME_PAUSED) { + return { kind: "paused", code, message: result.message }; + } + return { kind: "rejected", failure: result }; +} + +function pickCode(failure: SubmitFailure): string { + // The gateway sets `resultCode = backendError.Code` for non-ok + // replies (see `gateway/internal/backendclient/user_commands.go` + // `projectUserBackendError`). The FBS-encoded payload body is + // parsed by `submit.ts.decodeError`, which falls back to the + // `resultCode` when the body cannot be decoded; we therefore + // prefer `code` only when it differs from the result code, but + // either field carries the same authoritative value. + if (failure.code && failure.code !== failure.resultCode) { + return failure.code; + } + return failure.resultCode; +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + return "submit failed"; +} + +function defaultOnlineProbe(): boolean { + if (typeof navigator === "undefined") { + return true; + } + return navigator.onLine !== false; +} + +function defaultAddEventListener(event: string, handler: () => void): void { + if (typeof window === "undefined") return; + window.addEventListener(event, handler); +} + +function defaultRemoveEventListener(event: string, handler: () => void): void { + if (typeof window === "undefined") return; + window.removeEventListener(event, handler); +} diff --git a/ui/frontend/src/sync/order-types.ts b/ui/frontend/src/sync/order-types.ts index 25687ba..0fed7cb 100644 --- a/ui/frontend/src/sync/order-types.ts +++ b/ui/frontend/src/sync/order-types.ts @@ -558,20 +558,26 @@ export function isCargoLoadType(value: string): value is CargoLoadType { /** * CommandStatus is the lifecycle of a single command from the moment - * it lands in the draft to the moment the server resolves it. The - * skeleton stores only the type description; Phase 14 adds the - * `valid` / `invalid` transitions driven by local validation, and - * Phase 25 introduces `submitting` / `applied` / `rejected` driven - * by the submit pipeline. + * it lands in the draft to the moment the server resolves it. Phase + * 14 adds the `valid` / `invalid` transitions driven by local + * validation and the `submitting` / `applied` / `rejected` triplet + * driven by the submit pipeline; Phase 25 adds `conflict` for + * commands whose submit landed after the turn cutoff + * (`turn_already_closed` from the gateway). * * The state machine is: * * draft → valid → submitting → applied * ↘ invalid ↘ rejected + * ↘ conflict * * A command is `draft` until local validation has run, then `valid` * or `invalid`. On submit the entry transitions to `submitting`, - * then to `applied` or `rejected` once the gateway responds. + * then to `applied` / `rejected` / `conflict` once the gateway + * responds. A `conflict` row stays in the draft until the next + * `game.turn.ready` triggers a `resetForNewTurn`, or the user edits + * the draft (any mutation re-validates the conflict back to `valid` + * or `invalid`). */ export type CommandStatus = | "draft" @@ -579,4 +585,5 @@ export type CommandStatus = | "invalid" | "submitting" | "applied" - | "rejected"; + | "rejected" + | "conflict"; diff --git a/ui/frontend/tests/e2e/order-sync.spec.ts b/ui/frontend/tests/e2e/order-sync.spec.ts new file mode 100644 index 0000000..6c9ec4b --- /dev/null +++ b/ui/frontend/tests/e2e/order-sync.spec.ts @@ -0,0 +1,340 @@ +// Phase 25 end-to-end coverage for the sync protocol additions on +// the order tab: the offline / online flip, the +// `turn_already_closed` conflict banner, and the `game.paused` push +// frame. Each test boots an authenticated session, mocks the lobby +// + report + order routes, drives an order mutation through the +// inspector, and asserts the matching banner / sync-status DOM. + +import { fromJson, type JsonValue } from "@bufbuild/protobuf"; +import { expect, test, type Page } from "@playwright/test"; +import { ByteBuffer } from "flatbuffers"; + +import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb"; +import { UUID } from "../../src/proto/galaxy/fbs/common"; +import { + UserGamesOrder, + UserGamesOrderGet, +} from "../../src/proto/galaxy/fbs/order"; +import { GameReportRequest } from "../../src/proto/galaxy/fbs/report"; +import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; +import { forgeGatewayEventFrame } from "./fixtures/sign-event"; +import { + buildMyGamesListPayload, + type GameFixture, +} from "./fixtures/lobby-fbs"; +import { buildReportPayload } from "./fixtures/report-fbs"; +import { + buildOrderGetResponsePayload, + buildOrderResponsePayload, + type CommandResultFixture, +} from "./fixtures/order-fbs"; + +const SESSION_ID = "phase-25-order-sync-session"; +const GAME_ID = "25252525-2525-2525-2525-252525252525"; +const WORLD = 4000; +const CENTRE = WORLD / 2; +const TURN = 4; + +type SubmitVerdict = "applied" | "rejected" | "turn_already_closed" | "game_paused"; + +interface MockOpts { + /** Initial server-side order returned by `user.games.order.get`. */ + storedOrder?: CommandResultFixture[]; + /** How the first `user.games.order` submit replies. */ + initialSubmitVerdict: SubmitVerdict; + /** + * If set, the SubscribeEvents stream emits this frame instead of + * holding the connection open. Used by the paused-banner test. + */ + subscribeFrame?: { eventType: string; payload: Uint8Array }; +} + +interface MockHandle { + /** Setter the test uses to flip the verdict mid-run. */ + setSubmitVerdict(next: SubmitVerdict): void; + /** Read-only counter for assertion. */ + get submitCallCount(): number; +} + +async function mockGateway(page: Page, opts: MockOpts): Promise { + const game: GameFixture = { + gameId: GAME_ID, + gameName: "Phase 25 Game", + gameType: "private", + status: "running", + ownerUserId: "user-1", + minPlayers: 2, + maxPlayers: 8, + enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000), + createdAtMs: BigInt(Date.now() - 86_400_000), + updatedAtMs: BigInt(Date.now()), + currentTurn: TURN, + }; + + let storedOrder = (opts.storedOrder ?? []).slice(); + let submitVerdict: SubmitVerdict = opts.initialSubmitVerdict; + let submitCalls = 0; + + await page.route( + "**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand", + async (route) => { + const reqText = route.request().postData(); + if (reqText === null) { + await route.fulfill({ status: 400 }); + return; + } + const req = fromJson( + ExecuteCommandRequestSchema, + JSON.parse(reqText) as JsonValue, + ); + + let resultCode = "ok"; + let payload: Uint8Array; + let bodyOverride: string | null = null; + switch (req.messageType) { + case "lobby.my.games.list": + payload = buildMyGamesListPayload([game]); + break; + case "user.games.report": { + GameReportRequest.getRootAsGameReportRequest( + new ByteBuffer(req.payloadBytes), + ).gameId(new UUID()); + payload = buildReportPayload({ + turn: TURN, + mapWidth: WORLD, + mapHeight: WORLD, + localPlanets: [ + { + number: 17, + name: "Earth", + x: CENTRE, + y: CENTRE, + size: 1000, + resources: 10, + capital: 0, + material: 0, + population: 850, + colonists: 25, + industry: 700, + production: "drive", + freeIndustry: 175, + }, + ], + }); + break; + } + case "user.games.order": { + submitCalls += 1; + const decoded = UserGamesOrder.getRootAsUserGamesOrder( + new ByteBuffer(req.payloadBytes), + ); + const length = decoded.commandsLength(); + const fixtures: CommandResultFixture[] = []; + for (let i = 0; i < length; i++) { + const item = decoded.commands(i); + if (item === null) continue; + const cmdId = item.cmdId() ?? ""; + const inner = new (await import( + "../../src/proto/galaxy/fbs/order" + )).CommandPlanetRename(); + item.payload(inner); + const submittedName = inner.name() ?? ""; + const applied = submitVerdict === "applied"; + fixtures.push({ + kind: "planetRename", + cmdId, + planetNumber: Number(inner.number()), + name: submittedName, + applied, + errorCode: applied ? null : 1, + }); + } + if (submitVerdict === "turn_already_closed") { + resultCode = "turn_already_closed"; + bodyOverride = JSON.stringify({ + code: "turn_already_closed", + message: "turn closed before submit", + }); + } else if (submitVerdict === "game_paused") { + resultCode = "game_paused"; + bodyOverride = JSON.stringify({ + code: "game_paused", + message: "game is paused", + }); + } + if (submitVerdict === "applied") { + storedOrder = fixtures; + } + payload = + bodyOverride !== null + ? new TextEncoder().encode(bodyOverride) + : buildOrderResponsePayload(GAME_ID, fixtures, Date.now()); + break; + } + case "user.games.order.get": { + UserGamesOrderGet.getRootAsUserGamesOrderGet( + new ByteBuffer(req.payloadBytes), + ); + payload = buildOrderGetResponsePayload( + GAME_ID, + storedOrder, + Date.now(), + storedOrder.length > 0, + ); + break; + } + default: + resultCode = "internal_error"; + payload = new Uint8Array(); + } + + const body = await forgeExecuteCommandResponseJson({ + requestId: req.requestId, + timestampMs: BigInt(Date.now()), + resultCode, + payloadBytes: payload, + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body, + }); + }, + ); + + let subscribeServed = false; + await page.route( + "**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents", + async (route) => { + if (opts.subscribeFrame !== undefined && !subscribeServed) { + subscribeServed = true; + const frame = await forgeGatewayEventFrame({ + eventType: opts.subscribeFrame.eventType, + eventId: "evt-phase25-1", + timestampMs: BigInt(Date.now()), + requestId: "req-phase25-1", + traceId: "trace-phase25-1", + payloadBytes: opts.subscribeFrame.payload, + }); + await route.fulfill({ + status: 200, + contentType: "application/connect+json", + body: Buffer.from(frame), + }); + return; + } + await new Promise(() => {}); + }, + ); + + return { + setSubmitVerdict(next) { + submitVerdict = next; + }, + get submitCallCount() { + return submitCalls; + }, + }; +} + +async function bootSession(page: Page): Promise { + await page.goto("/__debug/store"); + await expect(page.getByTestId("debug-store-ready")).toBeVisible(); + await page.waitForFunction(() => window.__galaxyDebug?.ready === true); + await page.evaluate(() => window.__galaxyDebug!.clearSession()); + await page.evaluate( + (id) => window.__galaxyDebug!.setDeviceSessionId(id), + SESSION_ID, + ); + await page.evaluate( + (gameId) => window.__galaxyDebug!.clearOrderDraft(gameId), + GAME_ID, + ); +} + +async function clickPlanetCentre(page: Page): Promise { + const canvas = page.locator("canvas"); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) throw new Error("canvas has no bounding box"); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); +} + +async function startRename(page: Page, newName: string): Promise { + await clickPlanetCentre(page); + const sidebar = page.getByTestId("sidebar-tool-inspector"); + await sidebar.getByTestId("inspector-planet-rename-action").click(); + const input = sidebar.getByTestId("inspector-planet-rename-input"); + await input.fill(newName); + await sidebar.getByTestId("inspector-planet-rename-confirm").click(); +} + +test("turn_already_closed surfaces the conflict banner on the order tab", async ({ + page, +}, testInfo) => { + test.skip( + testInfo.project.name.startsWith("chromium-mobile"), + "phase 25 spec covers desktop layout; mobile inherits the same store", + ); + await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" }); + await bootSession(page); + await page.goto(`/games/${GAME_ID}/map`); + await expect(page.getByTestId("active-view-map")).toHaveAttribute( + "data-status", + "ready", + ); + + await startRename(page, "Conflict-Earth"); + + await page.getByTestId("sidebar-tab-order").click(); + const orderTool = page.getByTestId("sidebar-tool-order"); + await expect(orderTool.getByTestId("order-conflict-banner")).toBeVisible({ + timeout: 5_000, + }); + await expect(orderTool.getByTestId("order-conflict-banner")).toContainText( + "Edit and resubmit", + ); + await expect(orderTool.getByTestId("order-command-status-0")).toHaveText( + "conflict", + ); + await expect(orderTool.getByTestId("order-sync")).toHaveAttribute( + "data-sync-status", + "conflict", + ); +}); + +test("game.paused push frame surfaces the paused banner", async ({ + page, +}, testInfo) => { + test.skip( + testInfo.project.name.startsWith("chromium-mobile"), + "phase 25 spec covers desktop layout; mobile inherits the same store", + ); + const payload = new TextEncoder().encode( + JSON.stringify({ + game_id: GAME_ID, + turn: TURN, + reason: "generation_failed", + }), + ); + await mockGateway(page, { + initialSubmitVerdict: "applied", + subscribeFrame: { eventType: "game.paused", payload }, + }); + await bootSession(page); + await page.goto(`/games/${GAME_ID}/map`); + await expect(page.getByTestId("active-view-map")).toHaveAttribute( + "data-status", + "ready", + ); + + await page.getByTestId("sidebar-tab-order").click(); + const orderTool = page.getByTestId("sidebar-tool-order"); + await expect(orderTool.getByTestId("order-paused-banner")).toBeVisible({ + timeout: 5_000, + }); + await expect(orderTool.getByTestId("order-sync")).toHaveAttribute( + "data-sync-status", + "paused", + ); +}); diff --git a/ui/frontend/tests/events.test.ts b/ui/frontend/tests/events.test.ts index 795c788..f24ca97 100644 --- a/ui/frontend/tests/events.test.ts +++ b/ui/frontend/tests/events.test.ts @@ -291,6 +291,36 @@ describe("EventStream", () => { eventStream.stop(); }); + test("game.paused events dispatch to the matching handler (Phase 25)", async () => { + const handler = vi.fn(); + eventStream.on("game.paused", handler); + const payload = new TextEncoder().encode( + JSON.stringify({ + game_id: "11111111-2222-3333-4444-555555555555", + turn: 7, + reason: "generation_failed", + }), + ); + const event = buildEvent("game.paused", payload); + const client = makeRouter(async function* () { + yield event; + }); + eventStream.start({ + core: mockCore(), + keypair: mockKeypair(), + deviceSessionId: "device-1", + gatewayResponsePublicKey: new Uint8Array(32), + client, + sleep: async () => {}, + random: () => 0, + }); + await vi.waitFor(() => { + expect(handler).toHaveBeenCalled(); + }); + expect(handler.mock.calls[0]?.[0].eventType).toBe("game.paused"); + eventStream.stop(); + }); + test("connectionStatus transitions through connecting → connected → idle", async () => { expect(eventStream.connectionStatus).toBe("idle"); const event = buildEvent( diff --git a/ui/frontend/tests/helpers/fake-order-client.ts b/ui/frontend/tests/helpers/fake-order-client.ts index b125c54..5be01d3 100644 --- a/ui/frontend/tests/helpers/fake-order-client.ts +++ b/ui/frontend/tests/helpers/fake-order-client.ts @@ -33,10 +33,25 @@ interface RecordedCall { commandIds: string[]; } +/** + * RecordingOutcome enumerates the synthetic server reactions a test + * can drive through `recordingClient.setOutcome`. Phase 25 adds the + * `turn_already_closed` and `game_paused` codes (the order-queue + * classifies them into `conflict` / `paused` outcomes) and `throw` + * which lets the test exercise the network-error branch of + * `OrderQueue.send`. + */ +export type RecordingOutcome = + | "ok" + | "rejected" + | "turn_already_closed" + | "game_paused" + | "throw"; + interface RecordingHandle { client: GalaxyClient; calls: RecordedCall[]; - setOutcome(outcome: "ok" | "rejected"): void; + setOutcome(outcome: RecordingOutcome): void; waitForCalls(n: number): Promise; waitForIdle(): Promise; } @@ -51,11 +66,11 @@ interface RecordingHandle { */ export function recordingClient( gameId: string, - initialOutcome: "ok" | "rejected", + initialOutcome: RecordingOutcome, options: { delayMs?: number } = {}, ): RecordingHandle { const calls: RecordedCall[] = []; - let outcome: "ok" | "rejected" = initialOutcome; + let outcome: RecordingOutcome = initialOutcome; let inFlight = 0; const waiters: (() => void)[] = []; @@ -81,21 +96,45 @@ export function recordingClient( if (id !== null) commandIds.push(id); } calls.push({ messageType, commandIds }); - if (outcome === "ok") { - return { - resultCode: "ok", - payloadBytes: encodeApplied(gameId, commandIds, true), - }; + switch (outcome) { + case "ok": + return { + resultCode: "ok", + payloadBytes: encodeApplied(gameId, commandIds, true), + }; + case "turn_already_closed": + return { + resultCode: "turn_already_closed", + payloadBytes: new TextEncoder().encode( + JSON.stringify({ + code: "turn_already_closed", + message: "turn closed before submit", + }), + ), + }; + case "game_paused": + return { + resultCode: "game_paused", + payloadBytes: new TextEncoder().encode( + JSON.stringify({ + code: "game_paused", + message: "game is paused", + }), + ), + }; + case "throw": + throw new Error("network down"); + default: + return { + resultCode: "invalid_request", + payloadBytes: new TextEncoder().encode( + JSON.stringify({ + code: "validation_failed", + message: "rejected by fixture", + }), + ), + }; } - return { - resultCode: "invalid_request", - payloadBytes: new TextEncoder().encode( - JSON.stringify({ - code: "validation_failed", - message: "rejected by fixture", - }), - ), - }; } throw new Error(`unexpected messageType ${messageType}`); } finally { @@ -113,7 +152,7 @@ export function recordingClient( return { client, calls, - setOutcome(next: "ok" | "rejected") { + setOutcome(next: RecordingOutcome) { outcome = next; }, async waitForCalls(n: number) { diff --git a/ui/frontend/tests/order-draft.test.ts b/ui/frontend/tests/order-draft.test.ts index a4b0795..ee946e5 100644 --- a/ui/frontend/tests/order-draft.test.ts +++ b/ui/frontend/tests/order-draft.test.ts @@ -623,3 +623,189 @@ describe("OrderDraftStore auto-sync", () => { store.dispose(); }); }); + +describe("OrderDraftStore Phase 25 conflict / paused / offline", () => { + test("turn_already_closed marks the in-flight commands as conflict", async () => { + const { recordingClient } = await import("./helpers/fake-order-client"); + const handle = recordingClient(GAME_ID, "turn_already_closed"); + const store = new OrderDraftStore(); + await store.init({ cache, gameId: GAME_ID }); + store.bindClient(handle.client, { getCurrentTurn: () => 7 }); + + await store.add({ + kind: "planetRename", + id: "id-1", + planetNumber: 1, + name: "Earth", + }); + await handle.waitForCalls(1); + + expect(store.syncStatus).toBe("conflict"); + expect(store.statuses["id-1"]).toBe("conflict"); + expect(store.conflictBanner).not.toBeNull(); + expect(store.conflictBanner?.turn).toBe(7); + expect(store.conflictBanner?.code).toBe("turn_already_closed"); + store.dispose(); + }); + + test("mutating after a conflict clears the banner and revalidates", async () => { + const { recordingClient } = await import("./helpers/fake-order-client"); + const handle = recordingClient(GAME_ID, "turn_already_closed"); + const store = new OrderDraftStore(); + await store.init({ cache, gameId: GAME_ID }); + store.bindClient(handle.client, { getCurrentTurn: () => 3 }); + + await store.add({ + kind: "planetRename", + id: "id-1", + planetNumber: 1, + name: "Earth", + }); + await handle.waitForCalls(1); + expect(store.syncStatus).toBe("conflict"); + + // Adding a second command must wipe the conflict banner and + // re-validate the prior conflict-marked entry. Auto-sync + // re-fires (still seeing turn_already_closed) and the + // store ends up back in conflict for the new attempt. + handle.setOutcome("ok"); + await store.add({ + kind: "planetRename", + id: "id-2", + planetNumber: 2, + name: "Mars", + }); + await handle.waitForCalls(2); + expect(store.statuses["id-1"]).toBe("applied"); + expect(store.statuses["id-2"]).toBe("applied"); + expect(store.syncStatus).toBe("synced"); + expect(store.conflictBanner).toBeNull(); + store.dispose(); + }); + + test("game_paused outcome surfaces the pause banner and locks sync", async () => { + const { recordingClient } = await import("./helpers/fake-order-client"); + const handle = recordingClient(GAME_ID, "game_paused"); + const store = new OrderDraftStore(); + await store.init({ cache, gameId: GAME_ID }); + store.bindClient(handle.client); + + await store.add({ + kind: "planetRename", + id: "id-1", + planetNumber: 1, + name: "Earth", + }); + await handle.waitForCalls(1); + + expect(store.syncStatus).toBe("paused"); + expect(store.pausedBanner).not.toBeNull(); + expect(store.statuses["id-1"]).toBe("valid"); // reverted, not in flight + + // While paused, additional mutations should not trigger another + // submit — the queue would just hit the same wall. + const before = handle.calls.length; + store.forceSync(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(handle.calls.length).toBe(before); + store.dispose(); + }); + + test("markPaused projects a push event into the store", async () => { + const store = new OrderDraftStore(); + await store.init({ cache, gameId: GAME_ID }); + store.markPaused({ reason: "generation_failed" }); + expect(store.syncStatus).toBe("paused"); + expect(store.pausedBanner?.reason).toBe("generation_failed"); + store.dispose(); + }); + + test("resetForNewTurn clears the conflict banner and rehydrates", async () => { + const { fakeFetchClient, recordingClient } = await import( + "./helpers/fake-order-client" + ); + const recHandle = recordingClient(GAME_ID, "turn_already_closed"); + const store = new OrderDraftStore(); + await store.init({ cache, gameId: GAME_ID }); + store.bindClient(recHandle.client, { getCurrentTurn: () => 5 }); + + await store.add({ + kind: "planetRename", + id: "id-1", + planetNumber: 1, + name: "Earth", + }); + await recHandle.waitForCalls(1); + expect(store.syncStatus).toBe("conflict"); + + const { client: fetchClient } = fakeFetchClient(GAME_ID, [ + { + kind: "planetRename", + id: "fresh-1", + planetNumber: 9, + name: "Hydrated", + }, + ], 11); + await store.resetForNewTurn({ client: fetchClient, turn: 6 }); + expect(store.conflictBanner).toBeNull(); + expect(store.syncStatus).toBe("synced"); + expect(store.commands.map((c) => c.id)).toEqual(["fresh-1"]); + expect(store.updatedAt).toBe(11); + store.dispose(); + }); + + test("offline outcome holds the submit until online flips", async () => { + const { recordingClient } = await import("./helpers/fake-order-client"); + const handle = recordingClient(GAME_ID, "ok"); + const store = new OrderDraftStore(); + + // Stub the browser event surface so we can flip online/offline + // deterministically and assert the queue's reaction. + const listeners = new Map void>>(); + let online = false; + await store.init({ + cache, + gameId: GAME_ID, + queue: { + onlineProbe: () => online, + addEventListener: (event, handler) => { + let bucket = listeners.get(event); + if (bucket === undefined) { + bucket = new Set(); + listeners.set(event, bucket); + } + bucket.add(handler); + }, + removeEventListener: (event, handler) => { + listeners.get(event)?.delete(handler); + }, + onOnline: () => undefined, + }, + }); + store.bindClient(handle.client); + + await store.add({ + kind: "planetRename", + id: "id-1", + planetNumber: 1, + name: "Earth", + }); + + // Submission must NOT have left the queue while offline. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(handle.calls).toHaveLength(0); + expect(store.syncStatus).toBe("offline"); + expect(store.statuses["id-1"]).toBe("valid"); + + // Flip online and fire the browser `online` event; the queue's + // onOnline callback (set inside OrderDraftStore) schedules a + // fresh sync. + online = true; + const onlineBucket = listeners.get("online"); + onlineBucket?.forEach((h) => h()); + await handle.waitForCalls(1); + expect(store.statuses["id-1"]).toBe("applied"); + expect(store.syncStatus).toBe("synced"); + store.dispose(); + }); +}); diff --git a/ui/frontend/tests/order-queue.test.ts b/ui/frontend/tests/order-queue.test.ts new file mode 100644 index 0000000..0091420 --- /dev/null +++ b/ui/frontend/tests/order-queue.test.ts @@ -0,0 +1,232 @@ +// `OrderQueue` unit tests under JSDOM. The queue isolates the +// browser online/offline plumbing and the conflict / paused +// classification from the rest of the draft store, so these tests +// drive it directly with injected listeners and synthesised +// `SubmitResult`s. + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { OrderQueue, classifyResult } from "../src/sync/order-queue.svelte"; +import type { + SubmitFailure, + SubmitResult, + SubmitSuccess, +} from "../src/sync/submit"; + +interface FakeBrowser { + online: boolean; + listeners: Map void>>; + fireOnline: () => void; + fireOffline: () => void; +} + +function makeBrowser(initial: boolean): FakeBrowser { + const listeners = new Map void>>(); + const browser: FakeBrowser = { + online: initial, + listeners, + fireOnline: () => { + browser.online = true; + const bucket = listeners.get("online"); + if (bucket !== undefined) for (const h of [...bucket]) h(); + }, + fireOffline: () => { + browser.online = false; + const bucket = listeners.get("offline"); + if (bucket !== undefined) for (const h of [...bucket]) h(); + }, + }; + return browser; +} + +function startQueue( + browser: FakeBrowser, + onOnline: () => void = () => undefined, +): OrderQueue { + const queue = new OrderQueue(); + queue.start({ + onOnline, + onlineProbe: () => browser.online, + addEventListener: (event, handler) => { + let bucket = browser.listeners.get(event); + if (bucket === undefined) { + bucket = new Set(); + browser.listeners.set(event, bucket); + } + bucket.add(handler); + }, + removeEventListener: (event, handler) => { + const bucket = browser.listeners.get(event); + if (bucket !== undefined) bucket.delete(handler); + }, + }); + return queue; +} + +function success(updatedAt = 1): SubmitSuccess { + return { + ok: true, + results: new Map([["id", "applied"]]), + errorCodes: new Map([["id", null]]), + updatedAt, + }; +} + +function failure(resultCode: string, code = resultCode, message = ""): SubmitFailure { + return { + ok: false, + resultCode, + code, + message: message || resultCode, + }; +} + +describe("classifyResult", () => { + test("ok result maps to success", () => { + const out = classifyResult(success()); + expect(out.kind).toBe("success"); + }); + + test("turn_already_closed resultCode maps to conflict", () => { + const out = classifyResult(failure("turn_already_closed")); + expect(out.kind).toBe("conflict"); + if (out.kind === "conflict") { + expect(out.code).toBe("turn_already_closed"); + } + }); + + test("game_paused resultCode maps to paused", () => { + const out = classifyResult(failure("game_paused", "game_paused", "paused")); + expect(out.kind).toBe("paused"); + if (out.kind === "paused") { + expect(out.code).toBe("game_paused"); + expect(out.message).toBe("paused"); + } + }); + + test("turn_already_closed via inner code (resultCode opaque) maps to conflict", () => { + // gateway may set resultCode to something opaque while the + // FBS error body carries the actionable code. + const out = classifyResult(failure("conflict", "turn_already_closed")); + expect(out.kind).toBe("conflict"); + }); + + test("unknown failure code stays a rejected outcome", () => { + const out = classifyResult(failure("validation_failed")); + expect(out.kind).toBe("rejected"); + }); +}); + +describe("OrderQueue.send", () => { + let browser: FakeBrowser; + let queue: OrderQueue; + + beforeEach(() => { + browser = makeBrowser(true); + queue = startQueue(browser); + }); + + afterEach(() => { + queue.stop(); + }); + + test("offline at call time short-circuits without invoking submit", async () => { + browser.fireOffline(); + const submitFn = vi.fn<() => Promise>(); + const outcome = await queue.send(submitFn); + expect(outcome.kind).toBe("offline"); + expect(submitFn).not.toHaveBeenCalled(); + }); + + test("ok result is forwarded as success", async () => { + const outcome = await queue.send(async () => success(42)); + expect(outcome.kind).toBe("success"); + if (outcome.kind === "success") { + expect(outcome.result.updatedAt).toBe(42); + } + }); + + test("turn_already_closed reply maps to conflict outcome", async () => { + const outcome = await queue.send(async () => + failure("turn_already_closed", "turn_already_closed", "turn closed"), + ); + expect(outcome.kind).toBe("conflict"); + if (outcome.kind === "conflict") { + expect(outcome.message).toBe("turn closed"); + } + }); + + test("game_paused reply maps to paused outcome", async () => { + const outcome = await queue.send(async () => + failure("game_paused", "game_paused", "paused"), + ); + expect(outcome.kind).toBe("paused"); + }); + + test("throw while online maps to failed outcome", async () => { + const outcome = await queue.send(async () => { + throw new Error("boom"); + }); + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.reason).toBe("boom"); + } + }); + + test("throw with offline probe maps to offline outcome", async () => { + const outcome = await queue.send(async () => { + browser.online = false; + throw new Error("network down"); + }); + expect(outcome.kind).toBe("offline"); + expect(queue.online).toBe(false); + }); + + test("online event triggers the onOnline callback", () => { + const seen: number[] = []; + const newQueue = new OrderQueue(); + newQueue.start({ + onOnline: () => seen.push(seen.length + 1), + onlineProbe: () => browser.online, + addEventListener: (event, handler) => { + let bucket = browser.listeners.get(event); + if (bucket === undefined) { + bucket = new Set(); + browser.listeners.set(event, bucket); + } + bucket.add(handler); + }, + removeEventListener: (event, handler) => { + const bucket = browser.listeners.get(event); + if (bucket !== undefined) bucket.delete(handler); + }, + }); + browser.fireOffline(); + expect(newQueue.online).toBe(false); + browser.fireOnline(); + expect(newQueue.online).toBe(true); + expect(seen).toEqual([1]); + newQueue.stop(); + }); + + test("start is idempotent for the same queue instance", () => { + queue.start({ + onOnline: () => undefined, + onlineProbe: () => browser.online, + addEventListener: () => { + throw new Error("must not be called"); + }, + removeEventListener: () => undefined, + }); + expect(queue.online).toBe(true); + }); + + test("stop unsubscribes from browser events", () => { + queue.stop(); + const before = queue.online; + browser.fireOffline(); + // After stop, the queue no longer reflects browser flips. + expect(queue.online).toBe(before); + }); +}); diff --git a/ui/frontend/tests/order-tab.test.ts b/ui/frontend/tests/order-tab.test.ts index 3867572..eabed0d 100644 --- a/ui/frontend/tests/order-tab.test.ts +++ b/ui/frontend/tests/order-tab.test.ts @@ -175,4 +175,58 @@ describe("order-tab", () => { }); draft.dispose(); }); + + test("turn_already_closed surfaces the conflict banner with the turn", async () => { + const handle = recordingClient(GAME_ID, "turn_already_closed"); + const { draft, context } = await makeDraft([]); + draft.bindClient(handle.client, { getCurrentTurn: () => 12 }); + + const ui = render(OrderTab, { context }); + await draft.add({ + kind: "planetRename", + id: "id-1", + planetNumber: 1, + name: "Earth", + }); + await handle.waitForCalls(1); + + await waitFor(() => { + const banner = ui.getByTestId("order-conflict-banner"); + expect(banner).toBeVisible(); + expect(banner).toHaveTextContent("Turn 12"); + expect(banner).toHaveAttribute("data-conflict-turn", "12"); + }); + expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("conflict"); + expect(ui.getByTestId("order-sync")).toHaveAttribute( + "data-sync-status", + "conflict", + ); + draft.dispose(); + }); + + test("game_paused surfaces the paused banner and blocks retry", async () => { + const handle = recordingClient(GAME_ID, "game_paused"); + const { draft, context } = await makeDraft([]); + draft.bindClient(handle.client); + + const ui = render(OrderTab, { context }); + await draft.add({ + kind: "planetRename", + id: "id-1", + planetNumber: 1, + name: "Earth", + }); + await handle.waitForCalls(1); + + await waitFor(() => { + expect(ui.getByTestId("order-paused-banner")).toBeVisible(); + }); + expect(ui.getByTestId("order-sync")).toHaveAttribute( + "data-sync-status", + "paused", + ); + // No retry button is shown for paused state. + expect(ui.queryByTestId("order-sync-retry")).toBeNull(); + draft.dispose(); + }); });