ui: plan 01-27 done #1
+25
-7
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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` зарезервированы без поставщика;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user