ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
35 changed files with 2539 additions and 143 deletions
Showing only changes of commit 2ca47eb4df - Show all commits
+25 -7
View File
@@ -333,20 +333,38 @@ cannot guarantee.
| `runtime.image_pull_failed` | admin email | `game_id`, `image_ref` | | `runtime.image_pull_failed` | admin email | `game_id`, `image_ref` |
| `runtime.container_start_failed` | admin email | `game_id` | | `runtime.container_start_failed` | admin email | `game_id` |
| `runtime.start_config_invalid` | admin email | `game_id`, `reason` | | `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 Admin-channel kinds (`runtime.*`) deliver email to
`BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those `BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those
routes land in `notification_routes` with `status='skipped'` and the routes land in `notification_routes` with `status='skipped'` and the
operator log line records the configuration miss. operator log line records the configuration miss.
`game.turn.ready` is emitted by `lobby.Service.OnRuntimeSnapshot` `game.turn.ready` and `game.paused` are emitted by
(`backend/internal/lobby/runtime_hooks.go`) whenever the engine's `lobby.Service.OnRuntimeSnapshot`
`current_turn` advances. The intent targets every active membership (`backend/internal/lobby/runtime_hooks.go`):
of the game, uses idempotency key `turn-ready:<game_id>:<turn>`, and
carries the JSON payload `{game_id, turn}`. The catalog routes it - `game.turn.ready` fires whenever the engine's `current_turn`
through the push channel only — per-turn email would be spam — so advances. Idempotency key `turn-ready:<game_id>:<turn>`, 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:<game_id>:<turn>`, 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 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`, The remaining `game.*` (`game.started`, `game.generation.failed`,
`game.finished`) and `mail.dead_lettered` are reserved kinds without `game.finished`) and `mail.dead_lettered` are reserved kinds without
+1
View File
@@ -110,6 +110,7 @@ const (
NotificationLobbyRaceNamePending = "lobby.race_name.pending" NotificationLobbyRaceNamePending = "lobby.race_name.pending"
NotificationLobbyRaceNameExpired = "lobby.race_name.expired" NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
NotificationGameTurnReady = "game.turn.ready" NotificationGameTurnReady = "game.turn.ready"
NotificationGamePaused = "game.paused"
) )
// Deps aggregates every collaborator the lobby Service depends on. // Deps aggregates every collaborator the lobby Service depends on.
+73 -1
View File
@@ -37,6 +37,7 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
if err != nil { if err != nil {
return err return err
} }
transitionedToPaused := false
if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition { if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition {
switch next { switch next {
case GameStatusFinished: case GameStatusFinished:
@@ -53,12 +54,18 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
return err return err
} }
updated = rec updated = rec
if next == GameStatusPaused {
transitionedToPaused = true
}
} }
} }
s.deps.Cache.PutGame(updated) s.deps.Cache.PutGame(updated)
if merged.CurrentTurn > prevTurn { if merged.CurrentTurn > prevTurn {
s.publishTurnReady(ctx, gameID, merged.CurrentTurn) s.publishTurnReady(ctx, gameID, merged.CurrentTurn)
} }
if transitionedToPaused {
s.publishGamePaused(ctx, gameID, merged.CurrentTurn, snapshot.RuntimeStatus)
}
return nil 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 // OnGameFinished completes the game lifecycle: marks the game as
// `finished`, evaluates capable-finish per active member, and // `finished`, evaluates capable-finish per active member, and
// transitions reservation rows to either `pending_registration` // 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 // nextStatusFromSnapshot maps the runtime-reported runtime status into
// a lobby status transition. Returns (next, true) when the lobby // a lobby status transition. Returns (next, true) when the lobby
// status must change; (current, false) otherwise. // 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) { func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) {
switch snapshot.RuntimeStatus { switch snapshot.RuntimeStatus {
case "running": case "running":
if currentStatus == GameStatusStarting { if currentStatus == GameStatusStarting {
return GameStatusRunning, true 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 { if currentStatus == GameStatusStarting {
return GameStatusStartFailed, true return GameStatusStartFailed, true
} }
@@ -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)
}
})
}
}
+5
View File
@@ -18,6 +18,7 @@ const (
KindRuntimeContainerStartFailed = "runtime.container_start_failed" KindRuntimeContainerStartFailed = "runtime.container_start_failed"
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid" KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
KindGameTurnReady = "game.turn.ready" KindGameTurnReady = "game.turn.ready"
KindGamePaused = "game.paused"
) )
// CatalogEntry describes the per-kind delivery policy: which channels // CatalogEntry describes the per-kind delivery policy: which channels
@@ -99,6 +100,9 @@ var catalog = map[string]CatalogEntry{
KindGameTurnReady: { KindGameTurnReady: {
Channels: []string{ChannelPush}, Channels: []string{ChannelPush},
}, },
KindGamePaused: {
Channels: []string{ChannelPush},
},
} }
// LookupCatalog returns the per-kind policy and a boolean reporting // LookupCatalog returns the per-kind policy and a boolean reporting
@@ -128,5 +132,6 @@ func SupportedKinds() []string {
KindRuntimeContainerStartFailed, KindRuntimeContainerStartFailed,
KindRuntimeStartConfigInvalid, KindRuntimeStartConfigInvalid,
KindGameTurnReady, KindGameTurnReady,
KindGamePaused,
} }
} }
@@ -40,6 +40,7 @@ func TestCatalogChannels(t *testing.T) {
KindRuntimeContainerStartFailed: {ChannelEmail}, KindRuntimeContainerStartFailed: {ChannelEmail},
KindRuntimeStartConfigInvalid: {ChannelEmail}, KindRuntimeStartConfigInvalid: {ChannelEmail},
KindGameTurnReady: {ChannelPush}, KindGameTurnReady: {ChannelPush},
KindGamePaused: {ChannelPush},
} }
for kind, want := range expect { for kind, want := range expect {
entry, ok := LookupCatalog(kind) entry, ok := LookupCatalog(kind)
@@ -20,8 +20,14 @@ import (
// other consumer reads the payload — adopting the FB encoder would // other consumer reads the payload — adopting the FB encoder would
// require a new TS notification stub set and the regen tooling for // require a new TS notification stub set and the regen tooling for
// `pkg/schema/fbs/notification.fbs` without buying anything. // `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{ var jsonFriendlyKinds = map[string]bool{
KindGameTurnReady: true, KindGameTurnReady: true,
KindGamePaused: true,
} }
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind // TestBuildClientPushEventCoversCatalog asserts that every catalog kind
@@ -77,6 +83,11 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
"game_id": gameID.String(), "game_id": gameID.String(),
"turn": int32(7), "turn": int32(7),
}}, }},
{"game paused", KindGamePaused, map[string]any{
"game_id": gameID.String(),
"turn": int32(7),
"reason": "generation_failed",
}},
} }
seenKinds := map[string]bool{} seenKinds := map[string]bool{}
@@ -606,7 +606,7 @@ CREATE TABLE notifications (
'lobby.race_name.expired', 'lobby.race_name.expired',
'runtime.image_pull_failed', 'runtime.container_start_failed', 'runtime.image_pull_failed', 'runtime.container_start_failed',
'runtime.start_config_invalid', 'runtime.start_config_invalid',
'game.turn.ready' 'game.turn.ready', 'game.paused'
)) ))
); );
+19
View File
@@ -42,4 +42,23 @@ var (
// ErrShutdown means the runtime service has stopped accepting // ErrShutdown means the runtime service has stopped accepting
// work because the parent context was cancelled. // work because the parent context was cancelled.
ErrShutdown = errors.New("runtime: shutting down") 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")
) )
@@ -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)
}
})
}
}
+38 -1
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"galaxy/backend/internal/dockerclient" "galaxy/backend/internal/dockerclient"
"galaxy/backend/internal/engineclient"
"galaxy/cronutil" "galaxy/cronutil"
"github.com/google/uuid" "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, // tick runs one engine /admin/turn call under the per-game mutex,
// publishes the resulting snapshot, and clears `skip_next_tick`. // 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 { func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
mu := sch.svc.gameLock(rec.GameID) mu := sch.svc.gameLock(rec.GameID)
if !mu.TryLock() { if !mu.TryLock() {
@@ -224,10 +241,24 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
if err != nil { if err != nil {
return err 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) state, err := sch.svc.deps.Engine.Turn(ctx, rec.EngineEndpoint)
if err != nil { if err != nil {
sch.svc.completeOperation(ctx, op, err) 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 // On engine unreachable, also clear skip_next_tick so the next
// real tick can start fresh. // real tick can start fresh.
_ = sch.clearSkipFlag(ctx, rec.GameID) _ = 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) sch.svc.completeOperation(ctx, op, err)
return 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.svc.completeOperation(ctx, op, nil)
_ = sch.clearSkipFlag(ctx, rec.GameID) _ = sch.clearSkipFlag(ctx, rec.GameID)
return nil return nil
+78
View File
@@ -257,6 +257,57 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
return s.deps.Store.LoadPlayerMapping(ctx, gameID, userID) 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 // EngineEndpoint returns the engine endpoint URL for gameID. Used by
// the user game-proxy handlers. // the user game-proxy handlers.
func (s *Service) EngineEndpoint(ctx context.Context, gameID uuid.UUID) (string, error) { 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 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 // transitionRuntimeStatus updates the status / engine_health columns
// and refreshes the cache. // and refreshes the cache.
func (s *Service) transitionRuntimeStatus(ctx context.Context, gameID uuid.UUID, status, health string) (RuntimeRecord, error) { func (s *Service) transitionRuntimeStatus(ctx context.Context, gameID uuid.UUID, status, health string) (RuntimeRecord, error) {
@@ -60,6 +60,10 @@ func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
return return
} }
ctx := c.Request.Context() 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) mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil { if err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err) respondGameProxyError(c, h.logger, "user games commands", ctx, err)
@@ -105,6 +109,10 @@ func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
return return
} }
ctx := c.Request.Context() 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) mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil { if err != nil {
respondGameProxyError(c, h.logger, "user games orders", ctx, err) 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 { switch {
case errors.Is(err, runtime.ErrNotFound): case errors.Is(err, runtime.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game") 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): case errors.Is(err, runtime.ErrConflict):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error()) httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
default: default:
@@ -23,6 +23,22 @@ const (
CodeMethodNotAllowed = "method_not_allowed" CodeMethodNotAllowed = "method_not_allowed"
CodeInternalError = "internal_error" CodeInternalError = "internal_error"
CodeServiceUnavailable = "service_unavailable" 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. // Body stores the inner `error` object of the standard envelope.
+6 -3
View File
@@ -2314,9 +2314,10 @@ components:
type: string type: string
description: | description: |
Stable machine-readable failure marker. The closed set is Stable machine-readable failure marker. The closed set is
`not_implemented`, `invalid_request`, `unauthorized`, `not_found`, `not_implemented`, `invalid_request`, `unauthorized`,
`conflict`, `method_not_allowed`, `internal_error`, `forbidden`, `not_found`, `conflict`, `method_not_allowed`,
`service_unavailable`. `internal_error`, `service_unavailable`,
`turn_already_closed`, `game_paused`.
enum: enum:
- not_implemented - not_implemented
- invalid_request - invalid_request
@@ -2327,6 +2328,8 @@ components:
- method_not_allowed - method_not_allowed
- internal_error - internal_error
- service_unavailable - service_unavailable
- turn_already_closed
- game_paused
message: message:
type: string type: string
description: Human-readable client-safe failure description. description: Human-readable client-safe failure description.
+15 -3
View File
@@ -785,9 +785,21 @@ Future scale-out hooks (not in MVP):
- **runtime snapshot** — engine-status read materialised into the lobby's - **runtime snapshot** — engine-status read materialised into the lobby's
denormalised view: `current_turn`, `runtime_status`, denormalised view: `current_turn`, `runtime_status`,
`engine_health_summary`, `player_turn_stats`. `engine_health_summary`, `player_turn_stats`.
- **turn cutoff** — the `running → generation_in_progress` CAS transition - **turn cutoff** — the `running → generation_in_progress` runtime-status
that closes the command window. Commands arriving after the CAS are flip performed by `backend/internal/runtime/scheduler.go` before each
rejected. 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 - **outbox** — the durable queue of pending mail rows in
`mail_deliveries`, drained by the mail worker. `mail_deliveries`, drained by the mail worker.
- **freshness window** — the symmetric ±5-minute interval around server - **freshness window** — the symmetric ±5-minute interval around server
+48 -15
View File
@@ -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 the typed FB shape only to transcode the wire format; the per-command
semantics live in the engine. 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 A running game continuously alternates between a command-accepting
window and a generation phase. The transition `running → window and a generation phase, driven by the cron expression stored
generation_in_progress` is the cutoff: any command or order that in `runtime_records.turn_schedule`. The backend scheduler
arrives after the cutoff is rejected by backend before forwarding, (`backend/internal/runtime/scheduler.go`) wraps each engine
because the engine no longer accepts writes for the closing turn. `/admin/turn` call between two `runtime_status` flips:
After generation finishes, backend re-opens the window for the next
turn. - 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 `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 ### 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)) 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)). and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)).
Among the `game.*` notification kinds, `game.turn.ready` is wired: Among the `game.*` notification kinds, `game.turn.ready` and
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) `game.paused` are wired:
emits one intent per advancing `current_turn`, addressed to every
active membership of the game, with idempotency key - `game.turn.ready`
`turn-ready:<game_id>:<turn>` and JSON payload `{game_id, turn}`. The `lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
catalog routes the intent through the push channel only; email is emits one intent per advancing `current_turn`, addressed to every
deliberately omitted to avoid per-turn spam. active membership of the game, with idempotency key
`turn-ready:<game_id>:<turn>` 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:<game_id>:<turn>` 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`, The remaining `game.*` kinds (`game.started`, `game.generation.failed`,
`game.finished`) and `mail.dead_lettered` are reserved without a `game.finished`) and `mail.dead_lettered` are reserved without a
+50 -14
View File
@@ -653,17 +653,40 @@ Backend не парсит содержимое payload команд или пр
FB-форму только чтобы транскодировать wire-формат; per-command- FB-форму только чтобы транскодировать wire-формат; per-command-
семантика живёт в движке. семантика живёт в движке.
### 6.3 Окно хода ### 6.3 Окно хода и auto-pause
Запущенная игра постоянно чередуется между окном приёма команд Запущенная игра постоянно чередуется между окном приёма команд
и фазой генерации. Переход `running → generation_in_progress` и фазой генерации, управляемой cron-выражением из
cutoff: любая команда или приказ, пришедшие после cutoff, `runtime_records.turn_schedule`. Backend-планировщик
отклоняются backend до форварда, потому что движок больше не (`backend/internal/runtime/scheduler.go`) оборачивает каждый
принимает запись для закрывающегося хода. После окончания engine `/admin/turn` двумя `runtime_status`-флипами:
генерации backend заново открывает окно для следующего хода.
- Перед 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-доп-тик, который `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 Отчёты ### 6.4 Отчёты
@@ -690,13 +713,26 @@ status, per-player-stats). Engine-отчёт "game finished" гонит
([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name ([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name
Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)). Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)).
Из `game.*`-видов уведомлений подключён `game.turn.ready`: Из `game.*`-видов уведомлений подключены `game.turn.ready` и
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) `game.paused`:
выпускает один intent на каждое увеличение `current_turn`, адресуя
его всем активным membership-ам игры, с idempotency-ключом - `game.turn.ready`
`turn-ready:<game_id>:<turn>` и JSON-payload-ом `{game_id, turn}`. `lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
Каталог направляет intent только в push-канал; email-фан-аут выпускает один intent на каждое увеличение `current_turn`,
сознательно опущен, чтобы избежать спама на каждом ходе. адресуя его всем активным membership-ам игры, с
idempotency-ключом `turn-ready:<game_id>:<turn>` и
JSON-payload-ом `{game_id, turn}`.
- `game.paused` — тот же хук публикует один intent на каждое
выставление статуса `paused` по runtime-снапшоту
(`engine_unreachable` / `generation_failed`), адресуя его всем
активным membership-ам игры, с idempotency-ключом
`paused:<game_id>:<turn>` и JSON-payload-ом
`{game_id, turn, reason}`. `reason` несёт runtime-статус,
спровоцировавший переход, чтобы UI смог в будущем
дифференцировать копию.
Оба вида направляются только в push-канал; email-фан-аут
сознательно опущен, чтобы избежать спама на каждом ходе/паузе.
Остальные `game.*`-виды (`game.started`, `game.generation.failed`, Остальные `game.*`-виды (`game.started`, `game.generation.failed`,
`game.finished`) и `mail.dead_lettered` зарезервированы без поставщика; `game.finished`) и `mail.dead_lettered` зарезервированы без поставщика;
+6
View File
@@ -385,6 +385,12 @@ The current direct `Gateway -> User` self-service boundary uses that pattern:
- business error projection: - business error projection:
- gateway `result_code` - gateway `result_code`
- FlatBuffers error payload mirroring User Service `code` and `message` - 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`. The request envelope version literal is `v1`.
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`. `payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
+120 -26
View File
@@ -2671,43 +2671,137 @@ Targeted tests (delivered):
`game.turn.ready` frame surfaces the toast, manual dismiss `game.turn.ready` frame surfaces the toast, manual dismiss
clears it). 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 Goal: make the order draft survive transient connectivity issues
gracefully, with explicit user feedback on conflicts. **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 - Turn-cutoff enforcement lives in `backend` (not in `game-engine`).
the most recent submit; on reconnect, retry once; on persistent The scheduler flips `runtime_status` to `generation_in_progress`
failure, surface error to the order tab before each engine tick and back to `running` after; the
- conflict detection: if the server returns `turn_already_closed` for user-games handlers reject every command/order in
a submit, mark the entire draft as `conflict` and surface a non-running runtime states.
`Turn N closed before your order was accepted. Edit and resubmit.` - A failed engine tick auto-pauses the game (`running → paused`)
banner in the order tab through `lobby.OnRuntimeSnapshot`, and the lobby publishes a
- topic doc `ui/docs/sync-protocol.md` covering queue semantics, matching `game.paused` push event. Admin resume remains the
retry budgets, and conflict UX only way out of `paused`.
- The wire-level error codes are `turn_already_closed` (cutoff
conflict) and `game_paused` (paused / starting / finished / removed).
Gateway carries them through `projectUserBackendError` unchanged.
- The UI draft store delegates to a new `OrderQueue` (single-slot
pending, single retry on reconnect via `onOnline` callback). On
`game.turn.ready` after a conflict / pause, the layout calls
`OrderDraftStore.resetForNewTurn` which wipes the draft and
re-hydrates from the server for the new turn (old commands are
preserved server-side and can be read back via
`user.games.order.get?turn=N`).
Dependencies: Phases 14, 24. Backend artifacts:
- `backend/internal/notification/catalog.go`: new
`KindGamePaused = "game.paused"` and `catalog`/`SupportedKinds`
entries; matching `NotificationGamePaused` constant in
`backend/internal/lobby/lobby.go`; CHECK-constraint widened in
`backend/internal/postgres/migrations/00001_init.sql`.
- `backend/internal/lobby/runtime_hooks.go`:
`nextStatusFromSnapshot` flips `running → paused` on
`engine_unreachable` / `generation_failed`; new
`publishGamePaused` mirrors `publishTurnReady`, idempotency key
`paused:<game_id>:<turn>`, payload `{game_id, turn, reason}`.
- `backend/internal/runtime/scheduler.go`: `tick` wraps the engine
call with `generation_in_progress` / `running` flips and forwards
failure snapshots to lobby through
`Service.publishFailureSnapshot`.
- `backend/internal/runtime/service.go`: `CheckOrdersAccept` plus
the pure `OrdersAcceptStatus` helper used by both `Orders` and
`Commands` user-games handlers.
- `backend/internal/server/httperr/httperr.go`: new
`CodeTurnAlreadyClosed`, `CodeGamePaused`; openapi.yaml
`ErrorBody.code` enum extended.
- `backend/internal/server/handlers_user_games.go`:
`requireOrdersOpen` runs before forwarding, maps sentinels to
HTTP 409 + the matching code.
UI artifacts:
- `ui/frontend/src/sync/order-queue.svelte.ts` (new) — `OrderQueue`
class with offline detection, classification of
`turn_already_closed` / `game_paused`, dependency-injected
online probe + event listeners. Pure-function helper
`classifyResult` reused from tests.
- `ui/frontend/src/sync/order-types.ts` — `CommandStatus` gains
`conflict`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — wires
`OrderQueue` through `runSync`, adds `conflict` / `paused` /
`offline` to `SyncStatus`, plus `conflictBanner` /
`pausedBanner` runes, `markPaused`, `resetForNewTurn`,
`clearConflictForMutation`, sticky-`paused` guard in
`hydrateFromServer`. `bindClient(client, { getCurrentTurn })`
lets the conflict banner interpolate the turn number.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — renders
conflict / paused banners and the new `conflict` per-row badge;
status bar carries the offline / conflict / paused copy.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new keys for
`sync.{offline,conflict,paused}`, `conflict.banner`
(with `{turn}` interpolation) plus `banner_no_turn` fallback,
`paused.banner`, `status.conflict`.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
subscribes to `game.paused`; `game.turn.ready` handler now
triggers `resetForNewTurn` when the prior `syncStatus` was
`conflict` / `paused`. `bindClient` is invoked with
`getCurrentTurn: () => gameState.currentTurn`.
- `ui/docs/sync-protocol.md` (new) — send-loop semantics, retry
budget, conflict and paused UX, recovery paths.
- `ui/docs/order-composer.md` — stale Phase 25 paragraph
replaced with a pointer to the new topic doc; state-machine
diagram extended with the `conflict` transition.
Dependencies: Phases 14, 24; backend notification / lobby /
runtime modules.
Acceptance criteria: Acceptance criteria:
- submitting an order while offline queues it and submits successfully - submitting an order while offline queues it and submits
on reconnect; successfully on reconnect (one attempt on the next `online`
- a turn cutoff between draft and submit produces a visible conflict event, no inline retry storm);
banner with no data loss; - a turn cutoff between draft and submit produces a visible
- the order tab clearly distinguishes `draft`, `submitting`, conflict banner with the turn number; the local draft is
`accepted`, `rejected`, `conflict` states per command. 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: Targeted tests:
- Vitest unit tests for `order-queue` covering all state transitions; - Backend: `runtime_hooks_unit_test.go` for
- Playwright e2e: simulate network drop using Playwright's offline `nextStatusFromSnapshot`, `orders_accept_test.go` for the
mode, submit an order, restore network, confirm submission; per-record decision, plus existing testcontainer-backed
- regression test: force a turn cutoff during submit, assert conflict `runtime_hooks_test.go` covering the published intent. Catalog
banner appears. / 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 ## Phase 26. History Mode
+24 -7
View File
@@ -36,11 +36,18 @@ entry by `cmdId`. Successfully applied entries stay visible in
the draft (the player keeps composing until turn cutoff); the draft (the player keeps composing until turn cutoff);
rejected entries stay until the player edits or removes them. rejected entries stay until the player edits or removes them.
Phase 25 is reserved for one extension on top of this: per-line Phase 25 layers a transport-level policy on top of this baseline
sequencing if a future use case needs to submit commands without changing the batch semantics. The submit pipeline now
individually rather than in one batch. The wire shape is already goes through `OrderQueue` (see
flexible enough — the response carries an array of results — so [`sync-protocol.md`](sync-protocol.md)): the queue holds the
Phase 25 only changes the client-side iteration policy. 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 ## Local-validation invariant
@@ -63,8 +70,10 @@ submit pipeline filters the draft to `valid` entries only — any
```text ```text
draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
╲ │ ╲ │
╲──validate──▶ invalid ╲──nack──▶ rejected ╲──validate──▶ invalid ╲──nack──▶ rejected
╲────turn_already_closed──▶ conflict
``` ```
Transitions: Transitions:
@@ -76,6 +85,14 @@ Transitions:
the draft and sends it to the gateway. the draft and sends it to the gateway.
- **`submitting → applied` / `submitting → rejected`**: the gateway - **`submitting → applied` / `submitting → rejected`**: the gateway
responded; the entry is no longer in flight. 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`), Phase 14 lands the local validators (`draft → valid | invalid`),
the submit pipeline (`valid → submitting → applied | rejected`), the submit pipeline (`valid → submitting → applied | rejected`),
+217
View File
@@ -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.
+7
View File
@@ -128,13 +128,20 @@ const en = {
"game.sidebar.order.sync.in_flight": "syncing…", "game.sidebar.order.sync.in_flight": "syncing…",
"game.sidebar.order.sync.synced": "synced with server", "game.sidebar.order.sync.synced": "synced with server",
"game.sidebar.order.sync.error": "sync failed: {message}", "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.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.draft": "draft",
"game.sidebar.order.status.valid": "valid", "game.sidebar.order.status.valid": "valid",
"game.sidebar.order.status.invalid": "invalid", "game.sidebar.order.status.invalid": "invalid",
"game.sidebar.order.status.submitting": "submitting", "game.sidebar.order.status.submitting": "submitting",
"game.sidebar.order.status.applied": "applied", "game.sidebar.order.status.applied": "applied",
"game.sidebar.order.status.rejected": "rejected", "game.sidebar.order.status.rejected": "rejected",
"game.sidebar.order.status.conflict": "conflict",
"game.sidebar.order.label.placeholder": "{label}", "game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}", "game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
"game.sidebar.order.label.planet_production": "set production on planet {planet} → {target}", "game.sidebar.order.label.planet_production": "set production on planet {planet} → {target}",
+7
View File
@@ -129,13 +129,20 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.sync.in_flight": "синхронизация…", "game.sidebar.order.sync.in_flight": "синхронизация…",
"game.sidebar.order.sync.synced": "сохранено на сервере", "game.sidebar.order.sync.synced": "сохранено на сервере",
"game.sidebar.order.sync.error": "ошибка синхронизации: {message}", "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.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.draft": "черновик",
"game.sidebar.order.status.valid": "готова", "game.sidebar.order.status.valid": "готова",
"game.sidebar.order.status.invalid": "ошибка", "game.sidebar.order.status.invalid": "ошибка",
"game.sidebar.order.status.submitting": "отправка", "game.sidebar.order.status.submitting": "отправка",
"game.sidebar.order.status.applied": "принята", "game.sidebar.order.status.applied": "принята",
"game.sidebar.order.status.rejected": "отклонена", "game.sidebar.order.status.rejected": "отклонена",
"game.sidebar.order.status.conflict": "конфликт",
"game.sidebar.order.label.placeholder": "{label}", "game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}", "game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
"game.sidebar.order.label.planet_production": "сменить производство планеты {planet} → {target}", "game.sidebar.order.label.planet_production": "сменить производство планеты {planet} → {target}",
@@ -37,6 +37,7 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
submitting: "game.sidebar.order.status.submitting", submitting: "game.sidebar.order.status.submitting",
applied: "game.sidebar.order.status.applied", applied: "game.sidebar.order.status.applied",
rejected: "game.sidebar.order.status.rejected", rejected: "game.sidebar.order.status.rejected",
conflict: "game.sidebar.order.status.conflict",
}; };
function describe(cmd: OrderCommand): string { function describe(cmd: OrderCommand): string {
@@ -152,6 +153,32 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
<section class="tool" data-testid="sidebar-tool-order"> <section class="tool" data-testid="sidebar-tool-order">
<h3>{i18n.t("game.sidebar.tab.order")}</h3> <h3>{i18n.t("game.sidebar.tab.order")}</h3>
{#if draft !== undefined && draft.pausedBanner !== null}
<div
class="banner banner-paused"
data-testid="order-paused-banner"
data-paused-reason={draft.pausedBanner.reason}
role="status"
>
{i18n.t("game.sidebar.order.paused.banner")}
</div>
{/if}
{#if draft !== undefined && draft.conflictBanner !== null}
<div
class="banner banner-conflict"
data-testid="order-conflict-banner"
data-conflict-turn={draft.conflictBanner.turn ?? ""}
role="status"
>
{#if draft.conflictBanner.turn !== null}
{i18n.t("game.sidebar.order.conflict.banner", {
turn: String(draft.conflictBanner.turn),
})}
{:else}
{i18n.t("game.sidebar.order.conflict.banner_no_turn")}
{/if}
</div>
{/if}
{#if draft === undefined || draft.commands.length === 0} {#if draft === undefined || draft.commands.length === 0}
<p class="empty" data-testid="order-empty"> <p class="empty" data-testid="order-empty">
{i18n.t("game.sidebar.empty.order")} {i18n.t("game.sidebar.empty.order")}
@@ -202,6 +229,12 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
{i18n.t("game.sidebar.order.sync.error", { {i18n.t("game.sidebar.order.sync.error", {
message: draft.syncError ?? "", 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} {:else}
{i18n.t("game.sidebar.order.sync.idle")} {i18n.t("game.sidebar.order.sync.idle")}
{/if} {/if}
@@ -286,6 +319,27 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
color: #6d8cff; color: #6d8cff;
border-color: #2f3f6d; 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 { .delete {
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
@@ -317,6 +371,15 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
.sync-syncing { .sync-syncing {
color: #6d8cff; color: #6d8cff;
} }
.sync-offline {
color: #b9a566;
}
.sync-conflict {
color: #d99a4b;
}
.sync-paused {
color: #d4d4d4;
}
.sync-retry { .sync-retry {
font: inherit; font: inherit;
font-size: 0.8rem; font-size: 0.8rem;
@@ -229,11 +229,13 @@ fresh.
return new Uint8Array(digest); return new Uint8Array(digest);
} }
// `unsubTurnReady` carries the `eventStream.on(...)` disposer for // `unsubTurnReady` / `unsubGamePaused` carry the
// the game-scoped turn-ready handler. The layout registers the // `eventStream.on(...)` disposers for the game-scoped push
// handler once the local `GameStateStore` is initialised so an // handlers. The layout registers them once the local
// event arriving before `currentTurn` is known cannot misfire. // `GameStateStore` is initialised so an event arriving before
// `currentTurn` is known cannot misfire.
let unsubTurnReady: (() => void) | null = null; let unsubTurnReady: (() => void) | null = null;
let unsubGamePaused: (() => void) | null = null;
const turnReadyDecoder = new TextDecoder("utf-8"); const turnReadyDecoder = new TextDecoder("utf-8");
function parseTurnReadyPayload( function parseTurnReadyPayload(
@@ -261,6 +263,27 @@ fresh.
} }
} }
function parseGamePausedPayload(
event: VerifiedEvent,
): { gameId: string; reason: string } | null {
try {
const text = turnReadyDecoder.decode(event.payloadBytes);
const json: unknown = JSON.parse(text);
if (typeof json !== "object" || json === null) {
return null;
}
const record = json as Record<string, unknown>;
const eventGameId = record.game_id;
if (typeof eventGameId !== "string") {
return null;
}
const reason = typeof record.reason === "string" ? record.reason : "";
return { gameId: eventGameId, reason };
} catch {
return null;
}
}
let activeTurnReadyToastId: string | null = null; let activeTurnReadyToastId: string | null = null;
$effect(() => { $effect(() => {
@@ -340,20 +363,42 @@ fresh.
// while `gameState.init` is still in flight is not // while `gameState.init` is still in flight is not
// dropped by the singleton stream. `markPendingTurn` // dropped by the singleton stream. `markPendingTurn`
// already protects against turns that do not advance // 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) => { unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
const parsed = parseTurnReadyPayload(event); const parsed = parseTurnReadyPayload(event);
if (parsed === null || parsed.gameId !== gameId) { if (parsed === null || parsed.gameId !== gameId) {
return; return;
} }
gameState.markPendingTurn(parsed.turn); 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([ await Promise.all([
gameState.init({ client, cache, gameId }), gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId }),
]); ]);
galaxyClient.set(client); galaxyClient.set(client);
orderDraft.bindClient(client); orderDraft.bindClient(client, {
getCurrentTurn: () => gameState.currentTurn,
});
// The server is always polled at game boot — its // The server is always polled at game boot — its
// stored order may be fresher than the local cache // stored order may be fresher than the local cache
// (e.g. user is on a new device), and an offline // (e.g. user is on a new device), and an offline
@@ -375,6 +420,10 @@ fresh.
unsubTurnReady(); unsubTurnReady();
unsubTurnReady = null; unsubTurnReady = null;
} }
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
gameState.dispose(); gameState.dispose();
orderDraft.dispose(); orderDraft.dispose();
selection.dispose(); selection.dispose();
+286 -34
View File
@@ -24,6 +24,7 @@
import type { Cache } from "../platform/store/index"; import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client"; import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load"; import { fetchOrder } from "./order-load";
import { OrderQueue, type OrderQueueStartOptions } from "./order-queue.svelte";
import { import {
isRelation, isRelation,
isShipGroupCargo, isShipGroupCargo,
@@ -49,7 +50,47 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
type Status = "idle" | "ready" | "error"; 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 { export class OrderDraftStore {
commands: OrderCommand[] = $state([]); commands: OrderCommand[] = $state([]);
@@ -61,24 +102,52 @@ export class OrderDraftStore {
/** /**
* syncStatus reflects the auto-sync pipeline state for the order * syncStatus reflects the auto-sync pipeline state for the order
* tab status bar: * tab status bar:
* - `idle` — no sync attempted yet (e.g. fresh draft after * - `idle` — no sync attempted yet (e.g. fresh draft after
* hydration or before the first mutation). * hydration or before the first mutation).
* - `syncing` — a `submitOrder` call is in flight. * - `syncing` — a `submitOrder` call is in flight.
* - `synced` — the last sync succeeded; statuses match the * - `synced` — the last sync succeeded; statuses match the
* server's view. * server's view.
* - `error` — the last sync failed (network or non-`ok`); the * - `error` — the last sync failed (network or non-`ok`); the
* next mutation triggers a retry, or the user can * next mutation triggers a retry, or the user can
* force a re-sync via `forceSync`. * 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"); syncStatus: SyncStatus = $state("idle");
syncError: string | null = $state(null); 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 cache: Cache | null = null;
private gameId = ""; private gameId = "";
private destroyed = false; private destroyed = false;
private client: GalaxyClient | null = null; private client: GalaxyClient | null = null;
private syncing: Promise<void> | null = null; private syncing: Promise<void> | null = null;
private pending = false; 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` * 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 * authoritative read that always overwrites the local cache when
* the server has a stored order. * the server has a stored order.
*/ */
async init(opts: { cache: Cache; gameId: string }): Promise<void> { async init(
opts: { cache: Cache; gameId: string; queue?: OrderQueueStartOptions },
): Promise<void> {
this.cache = opts.cache; this.cache = opts.cache;
this.gameId = opts.gameId; this.gameId = opts.gameId;
try { try {
@@ -110,6 +181,7 @@ export class OrderDraftStore {
this.status = "error"; this.status = "error";
this.error = err instanceof Error ? err.message : "load failed"; 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 * this after the boot `Promise.all` resolves and before
* `hydrateFromServer`, so any mutation that lands afterwards goes * `hydrateFromServer`, so any mutation that lands afterwards goes
* through the network. * 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.client = client;
this.getCurrentTurn = opts.getCurrentTurn ?? null;
} }
/** /**
@@ -138,6 +219,14 @@ export class OrderDraftStore {
turn: number; turn: number;
}): Promise<void> { }): Promise<void> {
if (this.status !== "ready") return; 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; this.client = opts.client;
// Guard against placeholder game ids the Phase 10 e2e specs // Guard against placeholder game ids the Phase 10 e2e specs
// still use — auto-sync needs a real UUID for the FBS request // still use — auto-sync needs a real UUID for the FBS request
@@ -152,6 +241,11 @@ export class OrderDraftStore {
try { try {
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn); const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
if (this.destroyed) return; 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.commands = fetched.commands;
this.updatedAt = fetched.updatedAt; this.updatedAt = fetched.updatedAt;
this.recomputeStatuses(); this.recomputeStatuses();
@@ -166,11 +260,15 @@ export class OrderDraftStore {
} }
this.statuses = next; this.statuses = next;
await this.persist(); await this.persist();
this.syncStatus = "synced"; if ((this.syncStatus as SyncStatus) !== "paused") {
this.syncStatus = "synced";
}
} catch (err) { } catch (err) {
if (this.destroyed) return; if (this.destroyed) return;
this.syncStatus = "error"; if ((this.syncStatus as SyncStatus) !== "paused") {
this.syncError = err instanceof Error ? err.message : "fetch failed"; this.syncStatus = "error";
this.syncError = err instanceof Error ? err.message : "fetch failed";
}
console.warn("order-draft: server hydration failed", err); console.warn("order-draft: server hydration failed", err);
} }
} }
@@ -207,6 +305,7 @@ export class OrderDraftStore {
*/ */
async add(command: OrderCommand): Promise<void> { async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return; if (this.status !== "ready") return;
this.clearConflictForMutation();
const removed: string[] = []; const removed: string[] = [];
let nextCommands: OrderCommand[]; let nextCommands: OrderCommand[];
if (command.kind === "setProductionType") { if (command.kind === "setProductionType") {
@@ -288,6 +387,7 @@ export class OrderDraftStore {
if (this.status !== "ready") return; if (this.status !== "ready") return;
const next = this.commands.filter((cmd) => cmd.id !== id); const next = this.commands.filter((cmd) => cmd.id !== id);
if (next.length === this.commands.length) return; if (next.length === this.commands.length) return;
this.clearConflictForMutation();
this.commands = next; this.commands = next;
const nextStatuses = { ...this.statuses }; const nextStatuses = { ...this.statuses };
delete nextStatuses[id]; delete nextStatuses[id];
@@ -310,6 +410,7 @@ export class OrderDraftStore {
if (fromIndex < 0 || fromIndex >= length) return; if (fromIndex < 0 || fromIndex >= length) return;
if (toIndex < 0 || toIndex >= length) return; if (toIndex < 0 || toIndex >= length) return;
if (fromIndex === toIndex) return; if (fromIndex === toIndex) return;
this.clearConflictForMutation();
const next = [...this.commands]; const next = [...this.commands];
const [picked] = next.splice(fromIndex, 1); const [picked] = next.splice(fromIndex, 1);
if (picked === undefined) return; if (picked === undefined) return;
@@ -327,10 +428,61 @@ export class OrderDraftStore {
this.scheduleSync(); 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<void> {
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 { dispose(): void {
this.destroyed = true; this.destroyed = true;
this.cache = null; this.cache = null;
this.client = null; this.client = null;
this.getCurrentTurn = null;
if (this.queueStarted) {
this.queue.stop();
this.queueStarted = false;
}
} }
private scheduleSync(): void { private scheduleSync(): void {
@@ -338,6 +490,16 @@ export class OrderDraftStore {
// Same UUID guard as `hydrateFromServer` — placeholder game // Same UUID guard as `hydrateFromServer` — placeholder game
// ids in test fixtures must not blow up the auto-sync path. // ids in test fixtures must not blow up the auto-sync path.
if (!isUuid(this.gameId)) return; 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) { if (this.syncing !== null) {
this.pending = true; this.pending = true;
return; return;
@@ -378,45 +540,98 @@ export class OrderDraftStore {
this.syncStatus = "syncing"; this.syncStatus = "syncing";
this.syncError = null; this.syncError = null;
try { const outcome = await this.queue.send(() =>
const result = await submitOrder( submitOrder(client, this.gameId, submittable, {
client, updatedAt: this.updatedAt,
this.gameId, }),
submittable, );
{ updatedAt: this.updatedAt }, if (this.destroyed) return;
); switch (outcome.kind) {
if (this.destroyed) return; case "success": {
if (result.ok) { this.applyResultsInternal(
this.applyResultsInternal(result.results, result.updatedAt); outcome.result.results,
outcome.result.updatedAt,
);
// Even with `result.ok === true` an individual // Even with `result.ok === true` an individual
// command may have been rejected by the engine // command may have been rejected by the engine
// (e.g. validation passed transcoders but failed // (e.g. validation passed transcoders but failed
// the in-game rule). Surface that as an error in // the in-game rule). Surface that as an error in
// the sync bar so the player notices and can fix // the sync bar so the player notices and can fix
// or remove the offending command. // or remove the offending command.
const anyRejected = Array.from(result.results.values()).some( const anyRejected = Array.from(
(s) => s === "rejected", outcome.result.results.values(),
); ).some((s) => s === "rejected");
this.syncStatus = anyRejected ? "error" : "synced"; this.syncStatus = anyRejected ? "error" : "synced";
this.syncError = anyRejected this.syncError = anyRejected
? "engine rejected one or more commands" ? "engine rejected one or more commands"
: null; : null;
} else { break;
}
case "rejected": {
this.markRejectedInternal(submittingIds); this.markRejectedInternal(submittingIds);
this.syncStatus = "error"; 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; 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 { private markSubmittingInternal(ids: string[]): void {
const next = { ...this.statuses }; const next = { ...this.statuses };
for (const id of ids) { for (const id of ids) {
@@ -457,6 +672,43 @@ export class OrderDraftStore {
this.statuses = next; 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 { private revertSubmittingToValidInternal(): void {
const next = { ...this.statuses }; const next = { ...this.statuses };
for (const cmd of this.commands) { for (const cmd of this.commands) {
+231
View File
@@ -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<SubmitResult>): Promise<QueueOutcome> {
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);
}
+14 -7
View File
@@ -558,20 +558,26 @@ export function isCargoLoadType(value: string): value is CargoLoadType {
/** /**
* CommandStatus is the lifecycle of a single command from the moment * CommandStatus is the lifecycle of a single command from the moment
* it lands in the draft to the moment the server resolves it. The * it lands in the draft to the moment the server resolves it. Phase
* skeleton stores only the type description; Phase 14 adds the * 14 adds the `valid` / `invalid` transitions driven by local
* `valid` / `invalid` transitions driven by local validation, and * validation and the `submitting` / `applied` / `rejected` triplet
* Phase 25 introduces `submitting` / `applied` / `rejected` driven * driven by the submit pipeline; Phase 25 adds `conflict` for
* by the submit pipeline. * commands whose submit landed after the turn cutoff
* (`turn_already_closed` from the gateway).
* *
* The state machine is: * The state machine is:
* *
* draft → valid → submitting → applied * draft → valid → submitting → applied
* ↘ invalid ↘ rejected * ↘ invalid ↘ rejected
* ↘ conflict
* *
* A command is `draft` until local validation has run, then `valid` * A command is `draft` until local validation has run, then `valid`
* or `invalid`. On submit the entry transitions to `submitting`, * 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 = export type CommandStatus =
| "draft" | "draft"
@@ -579,4 +585,5 @@ export type CommandStatus =
| "invalid" | "invalid"
| "submitting" | "submitting"
| "applied" | "applied"
| "rejected"; | "rejected"
| "conflict";
+340
View File
@@ -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<MockHandle> {
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<void>(() => {});
},
);
return {
setSubmitVerdict(next) {
submitVerdict = next;
},
get submitCallCount() {
return submitCalls;
},
};
}
async function bootSession(page: Page): Promise<void> {
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<void> {
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<void> {
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",
);
});
+30
View File
@@ -291,6 +291,36 @@ describe("EventStream", () => {
eventStream.stop(); 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 () => { test("connectionStatus transitions through connecting → connected → idle", async () => {
expect(eventStream.connectionStatus).toBe("idle"); expect(eventStream.connectionStatus).toBe("idle");
const event = buildEvent( const event = buildEvent(
+57 -18
View File
@@ -33,10 +33,25 @@ interface RecordedCall {
commandIds: string[]; 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 { interface RecordingHandle {
client: GalaxyClient; client: GalaxyClient;
calls: RecordedCall[]; calls: RecordedCall[];
setOutcome(outcome: "ok" | "rejected"): void; setOutcome(outcome: RecordingOutcome): void;
waitForCalls(n: number): Promise<void>; waitForCalls(n: number): Promise<void>;
waitForIdle(): Promise<void>; waitForIdle(): Promise<void>;
} }
@@ -51,11 +66,11 @@ interface RecordingHandle {
*/ */
export function recordingClient( export function recordingClient(
gameId: string, gameId: string,
initialOutcome: "ok" | "rejected", initialOutcome: RecordingOutcome,
options: { delayMs?: number } = {}, options: { delayMs?: number } = {},
): RecordingHandle { ): RecordingHandle {
const calls: RecordedCall[] = []; const calls: RecordedCall[] = [];
let outcome: "ok" | "rejected" = initialOutcome; let outcome: RecordingOutcome = initialOutcome;
let inFlight = 0; let inFlight = 0;
const waiters: (() => void)[] = []; const waiters: (() => void)[] = [];
@@ -81,21 +96,45 @@ export function recordingClient(
if (id !== null) commandIds.push(id); if (id !== null) commandIds.push(id);
} }
calls.push({ messageType, commandIds }); calls.push({ messageType, commandIds });
if (outcome === "ok") { switch (outcome) {
return { case "ok":
resultCode: "ok", return {
payloadBytes: encodeApplied(gameId, commandIds, true), 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}`); throw new Error(`unexpected messageType ${messageType}`);
} finally { } finally {
@@ -113,7 +152,7 @@ export function recordingClient(
return { return {
client, client,
calls, calls,
setOutcome(next: "ok" | "rejected") { setOutcome(next: RecordingOutcome) {
outcome = next; outcome = next;
}, },
async waitForCalls(n: number) { async waitForCalls(n: number) {
+186
View File
@@ -623,3 +623,189 @@ describe("OrderDraftStore auto-sync", () => {
store.dispose(); 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<void>((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<string, Set<() => 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<void>((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();
});
});
+232
View File
@@ -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<string, Set<() => void>>;
fireOnline: () => void;
fireOffline: () => void;
}
function makeBrowser(initial: boolean): FakeBrowser {
const listeners = new Map<string, Set<() => 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<SubmitResult>>();
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);
});
});
+54
View File
@@ -175,4 +175,58 @@ describe("order-tab", () => {
}); });
draft.dispose(); 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();
});
}); });