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.container_start_failed` | admin email | `game_id` |
|
||||
| `runtime.start_config_invalid` | admin email | `game_id`, `reason` |
|
||||
| `game.turn.ready` | push | `game_id`, `turn` |
|
||||
| `game.paused` | push | `game_id`, `turn`, `reason` |
|
||||
|
||||
Admin-channel kinds (`runtime.*`) deliver email to
|
||||
`BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those
|
||||
routes land in `notification_routes` with `status='skipped'` and the
|
||||
operator log line records the configuration miss.
|
||||
|
||||
`game.turn.ready` is emitted by `lobby.Service.OnRuntimeSnapshot`
|
||||
(`backend/internal/lobby/runtime_hooks.go`) whenever the engine's
|
||||
`current_turn` advances. The intent targets every active membership
|
||||
of the game, uses idempotency key `turn-ready:<game_id>:<turn>`, and
|
||||
carries the JSON payload `{game_id, turn}`. The catalog routes it
|
||||
through the push channel only — per-turn email would be spam — so
|
||||
`game.turn.ready` and `game.paused` are emitted by
|
||||
`lobby.Service.OnRuntimeSnapshot`
|
||||
(`backend/internal/lobby/runtime_hooks.go`):
|
||||
|
||||
- `game.turn.ready` fires whenever the engine's `current_turn`
|
||||
advances. Idempotency key `turn-ready:<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
|
||||
(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery path.
|
||||
(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery
|
||||
path. The order tab consumes them via
|
||||
`OrderDraftStore.resetForNewTurn` / `markPaused`
|
||||
(`ui/docs/sync-protocol.md`).
|
||||
|
||||
The remaining `game.*` (`game.started`, `game.generation.failed`,
|
||||
`game.finished`) and `mail.dead_lettered` are reserved kinds without
|
||||
|
||||
@@ -110,6 +110,7 @@ const (
|
||||
NotificationLobbyRaceNamePending = "lobby.race_name.pending"
|
||||
NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
|
||||
NotificationGameTurnReady = "game.turn.ready"
|
||||
NotificationGamePaused = "game.paused"
|
||||
)
|
||||
|
||||
// Deps aggregates every collaborator the lobby Service depends on.
|
||||
|
||||
@@ -37,6 +37,7 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
transitionedToPaused := false
|
||||
if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition {
|
||||
switch next {
|
||||
case GameStatusFinished:
|
||||
@@ -53,12 +54,18 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
|
||||
return err
|
||||
}
|
||||
updated = rec
|
||||
if next == GameStatusPaused {
|
||||
transitionedToPaused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
s.deps.Cache.PutGame(updated)
|
||||
if merged.CurrentTurn > prevTurn {
|
||||
s.publishTurnReady(ctx, gameID, merged.CurrentTurn)
|
||||
}
|
||||
if transitionedToPaused {
|
||||
s.publishGamePaused(ctx, gameID, merged.CurrentTurn, snapshot.RuntimeStatus)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -106,6 +113,56 @@ func (s *Service) publishTurnReady(ctx context.Context, gameID uuid.UUID, turn i
|
||||
}
|
||||
}
|
||||
|
||||
// publishGamePaused fans out a `game.paused` notification to every
|
||||
// active member of the game when the lobby flips the game to
|
||||
// `paused` in reaction to a runtime snapshot (typically a failed
|
||||
// turn generation). The intent is best-effort: a publisher failure
|
||||
// is logged at warn level and does not abort the snapshot
|
||||
// bookkeeping. Idempotency is anchored on (game_id, turn) so a
|
||||
// repeated `generation_failed` snapshot for the same turn collapses
|
||||
// into a single notification at the notification.Submit boundary.
|
||||
//
|
||||
// reason carries the raw runtime status that triggered the pause
|
||||
// (`engine_unreachable` / `generation_failed`); the UI displays a
|
||||
// status-agnostic banner today but the payload is preserved so a
|
||||
// future revision of the order tab can differentiate.
|
||||
func (s *Service) publishGamePaused(ctx context.Context, gameID uuid.UUID, turn int32, reason string) {
|
||||
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("game-paused notification: list memberships failed",
|
||||
zap.String("game_id", gameID.String()),
|
||||
zap.Int32("turn", turn),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
recipients := make([]uuid.UUID, 0, len(memberships))
|
||||
for _, m := range memberships {
|
||||
if m.Status != MembershipStatusActive {
|
||||
continue
|
||||
}
|
||||
recipients = append(recipients, m.UserID)
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
intent := LobbyNotification{
|
||||
Kind: NotificationGamePaused,
|
||||
IdempotencyKey: fmt.Sprintf("paused:%s:%d", gameID, turn),
|
||||
Recipients: recipients,
|
||||
Payload: map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"turn": turn,
|
||||
"reason": reason,
|
||||
},
|
||||
}
|
||||
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
|
||||
s.deps.Logger.Warn("game-paused notification failed",
|
||||
zap.String("game_id", gameID.String()),
|
||||
zap.Int32("turn", turn),
|
||||
zap.Error(pubErr))
|
||||
}
|
||||
}
|
||||
|
||||
// OnGameFinished completes the game lifecycle: marks the game as
|
||||
// `finished`, evaluates capable-finish per active member, and
|
||||
// transitions reservation rows to either `pending_registration`
|
||||
@@ -278,13 +335,28 @@ func mergeRuntimeSnapshot(prev, next RuntimeSnapshot) RuntimeSnapshot {
|
||||
// nextStatusFromSnapshot maps the runtime-reported runtime status into
|
||||
// a lobby status transition. Returns (next, true) when the lobby
|
||||
// status must change; (current, false) otherwise.
|
||||
//
|
||||
// The map intentionally distinguishes the pre-running boot path
|
||||
// (`starting → start_failed`) from the in-flight failure path
|
||||
// (`running → paused`). Paused games can be resumed by the admin via
|
||||
// the explicit `/resume` transition; the runtime keeps the engine
|
||||
// container alive, the scheduler short-circuits ticks while paused,
|
||||
// and any user-games command/order is rejected by the order handler
|
||||
// with `turn_already_closed` until the game resumes.
|
||||
func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) {
|
||||
switch snapshot.RuntimeStatus {
|
||||
case "running":
|
||||
if currentStatus == GameStatusStarting {
|
||||
return GameStatusRunning, true
|
||||
}
|
||||
case "engine_unreachable", "start_failed", "generation_failed":
|
||||
case "engine_unreachable", "generation_failed":
|
||||
if currentStatus == GameStatusStarting {
|
||||
return GameStatusStartFailed, true
|
||||
}
|
||||
if currentStatus == GameStatusRunning {
|
||||
return GameStatusPaused, true
|
||||
}
|
||||
case "start_failed":
|
||||
if currentStatus == GameStatusStarting {
|
||||
return GameStatusStartFailed, true
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
||||
KindGameTurnReady = "game.turn.ready"
|
||||
KindGamePaused = "game.paused"
|
||||
)
|
||||
|
||||
// CatalogEntry describes the per-kind delivery policy: which channels
|
||||
@@ -99,6 +100,9 @@ var catalog = map[string]CatalogEntry{
|
||||
KindGameTurnReady: {
|
||||
Channels: []string{ChannelPush},
|
||||
},
|
||||
KindGamePaused: {
|
||||
Channels: []string{ChannelPush},
|
||||
},
|
||||
}
|
||||
|
||||
// LookupCatalog returns the per-kind policy and a boolean reporting
|
||||
@@ -128,5 +132,6 @@ func SupportedKinds() []string {
|
||||
KindRuntimeContainerStartFailed,
|
||||
KindRuntimeStartConfigInvalid,
|
||||
KindGameTurnReady,
|
||||
KindGamePaused,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestCatalogChannels(t *testing.T) {
|
||||
KindRuntimeContainerStartFailed: {ChannelEmail},
|
||||
KindRuntimeStartConfigInvalid: {ChannelEmail},
|
||||
KindGameTurnReady: {ChannelPush},
|
||||
KindGamePaused: {ChannelPush},
|
||||
}
|
||||
for kind, want := range expect {
|
||||
entry, ok := LookupCatalog(kind)
|
||||
|
||||
@@ -20,8 +20,14 @@ import (
|
||||
// other consumer reads the payload — adopting the FB encoder would
|
||||
// require a new TS notification stub set and the regen tooling for
|
||||
// `pkg/schema/fbs/notification.fbs` without buying anything.
|
||||
//
|
||||
// `game.paused` (Phase 25) follows the same JSON-friendly contract:
|
||||
// payload is `{game_id, turn, reason}` consumed by the same in-game
|
||||
// shell layout, so there is no value in dragging a FB schema in for
|
||||
// one consumer.
|
||||
var jsonFriendlyKinds = map[string]bool{
|
||||
KindGameTurnReady: true,
|
||||
KindGamePaused: true,
|
||||
}
|
||||
|
||||
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
||||
@@ -77,6 +83,11 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
||||
"game_id": gameID.String(),
|
||||
"turn": int32(7),
|
||||
}},
|
||||
{"game paused", KindGamePaused, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"turn": int32(7),
|
||||
"reason": "generation_failed",
|
||||
}},
|
||||
}
|
||||
|
||||
seenKinds := map[string]bool{}
|
||||
|
||||
@@ -606,7 +606,7 @@ CREATE TABLE notifications (
|
||||
'lobby.race_name.expired',
|
||||
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
||||
'runtime.start_config_invalid',
|
||||
'game.turn.ready'
|
||||
'game.turn.ready', 'game.paused'
|
||||
))
|
||||
);
|
||||
|
||||
|
||||
@@ -42,4 +42,23 @@ var (
|
||||
// ErrShutdown means the runtime service has stopped accepting
|
||||
// work because the parent context was cancelled.
|
||||
ErrShutdown = errors.New("runtime: shutting down")
|
||||
|
||||
// ErrTurnAlreadyClosed reports that the runtime is currently
|
||||
// producing a turn — runtime status is `generation_in_progress`
|
||||
// — and the engine is not accepting writes for the closing
|
||||
// turn. Handlers map this to HTTP 409 with httperr code
|
||||
// `turn_already_closed`; the UI shows a conflict banner and
|
||||
// waits for the next `game.turn.ready` push.
|
||||
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
||||
|
||||
// ErrGamePaused reports that the game is not in a state that
|
||||
// accepts user-games commands or orders: the runtime row
|
||||
// carries `paused = true`, or the runtime status lands on any
|
||||
// terminal value (`engine_unreachable`, `generation_failed`,
|
||||
// `stopped`, `finished`, `removed`), or the game has not yet
|
||||
// finished bootstrapping (`starting`). Handlers map this to
|
||||
// HTTP 409 with httperr code `game_paused`; the UI surfaces a
|
||||
// pause banner and waits for an admin resume or a fresh
|
||||
// snapshot.
|
||||
ErrGamePaused = errors.New("runtime: game paused")
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
"galaxy/backend/internal/dockerclient"
|
||||
"galaxy/backend/internal/engineclient"
|
||||
"galaxy/cronutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -213,6 +214,22 @@ func (sch *Scheduler) loop(ctx context.Context, rec RuntimeRecord, done chan str
|
||||
|
||||
// tick runs one engine /admin/turn call under the per-game mutex,
|
||||
// publishes the resulting snapshot, and clears `skip_next_tick`.
|
||||
//
|
||||
// Phase 25 wraps the engine call between two runtime-status flips so
|
||||
// the backend order handler can reject late submits while the engine
|
||||
// is producing:
|
||||
//
|
||||
// - before `Engine.Turn`: runtime status moves to
|
||||
// `generation_in_progress`; the loop's running-only guard tolerates
|
||||
// this because the flip back happens inside the same tick.
|
||||
// - on success: runtime status moves back to `running` (unless the
|
||||
// engine reports `finished`, in which case `publishSnapshot` has
|
||||
// already promoted the row to `finished`).
|
||||
// - on error: runtime status moves to `generation_failed` (engine
|
||||
// validation failure) or `engine_unreachable` (transport / 5xx).
|
||||
// The matching snapshot is forwarded to lobby through
|
||||
// `publishFailureSnapshot` so lobby can flip the game to `paused`
|
||||
// and emit `game.paused`.
|
||||
func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
|
||||
mu := sch.svc.gameLock(rec.GameID)
|
||||
if !mu.TryLock() {
|
||||
@@ -224,10 +241,24 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusGenerationInProgress, ""); err != nil {
|
||||
sch.svc.completeOperation(ctx, op, err)
|
||||
return err
|
||||
}
|
||||
state, err := sch.svc.deps.Engine.Turn(ctx, rec.EngineEndpoint)
|
||||
if err != nil {
|
||||
sch.svc.completeOperation(ctx, op, err)
|
||||
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusEngineUnreachable, "")
|
||||
failureStatus := RuntimeStatusEngineUnreachable
|
||||
if errors.Is(err, engineclient.ErrEngineValidation) {
|
||||
failureStatus = RuntimeStatusGenerationFailed
|
||||
}
|
||||
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, failureStatus, "down")
|
||||
if pubErr := sch.svc.publishFailureSnapshot(ctx, rec.GameID, failureStatus); pubErr != nil {
|
||||
sch.svc.deps.Logger.Warn("publish failure snapshot to lobby",
|
||||
zap.String("game_id", rec.GameID.String()),
|
||||
zap.String("runtime_status", failureStatus),
|
||||
zap.Error(pubErr))
|
||||
}
|
||||
// On engine unreachable, also clear skip_next_tick so the next
|
||||
// real tick can start fresh.
|
||||
_ = sch.clearSkipFlag(ctx, rec.GameID)
|
||||
@@ -244,6 +275,12 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
|
||||
sch.svc.completeOperation(ctx, op, err)
|
||||
return err
|
||||
}
|
||||
if !state.Finished {
|
||||
// `publishSnapshot` patches CurrentTurn / EngineHealth but does
|
||||
// not reset the status column; reopen the orders window here so
|
||||
// the next loop iteration finds the runtime back in `running`.
|
||||
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusRunning, "ok")
|
||||
}
|
||||
sch.svc.completeOperation(ctx, op, nil)
|
||||
_ = sch.clearSkipFlag(ctx, rec.GameID)
|
||||
return nil
|
||||
|
||||
@@ -257,6 +257,57 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
|
||||
return s.deps.Store.LoadPlayerMapping(ctx, gameID, userID)
|
||||
}
|
||||
|
||||
// CheckOrdersAccept verifies that the runtime is in a state that
|
||||
// accepts user-games commands and orders. It is called by the user
|
||||
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
|
||||
// engine, so the backend's turn-cutoff and pause guards run before
|
||||
// network traffic leaves the host. The decision itself lives in the
|
||||
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
||||
// constructing a full Service.
|
||||
//
|
||||
// A missing runtime row is surfaced as `ErrNotFound` so the handler
|
||||
// keeps its existing 404 behaviour.
|
||||
func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error {
|
||||
rec, err := s.GetRuntime(ctx, gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return OrdersAcceptStatus(rec)
|
||||
}
|
||||
|
||||
// OrdersAcceptStatus inspects a runtime record and returns the
|
||||
// matching sentinel for the user-games order/command pre-check:
|
||||
//
|
||||
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
|
||||
// The cron-driven `Scheduler.tick` has flipped the row before
|
||||
// calling the engine. The order window reopens once the tick
|
||||
// completes successfully.
|
||||
//
|
||||
// - `runtime_status ∈ {engine_unreachable, generation_failed,
|
||||
// stopped, finished, removed, starting}` → `ErrGamePaused`.
|
||||
// The game is not in a state that accepts writes; the lobby
|
||||
// state machine has either already flipped the game to
|
||||
// `paused` / `finished` or is still bootstrapping.
|
||||
//
|
||||
// - `runtime.Paused = true` → `ErrGamePaused`. The lobby admin
|
||||
// paused the game explicitly.
|
||||
//
|
||||
// - `runtime_status = running` and `Paused = false` → nil
|
||||
// (forward).
|
||||
func OrdersAcceptStatus(rec RuntimeRecord) error {
|
||||
if rec.Paused {
|
||||
return ErrGamePaused
|
||||
}
|
||||
switch rec.Status {
|
||||
case RuntimeStatusRunning:
|
||||
return nil
|
||||
case RuntimeStatusGenerationInProgress:
|
||||
return ErrTurnAlreadyClosed
|
||||
default:
|
||||
return ErrGamePaused
|
||||
}
|
||||
}
|
||||
|
||||
// EngineEndpoint returns the engine endpoint URL for gameID. Used by
|
||||
// the user game-proxy handlers.
|
||||
func (s *Service) EngineEndpoint(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
@@ -812,6 +863,33 @@ func (s *Service) publishSnapshot(ctx context.Context, gameID uuid.UUID, state r
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishFailureSnapshot forwards a runtime-failure observation to
|
||||
// lobby so the game lifecycle can react (e.g. flipping `running` to
|
||||
// `paused` on `engine_unreachable` / `generation_failed` per Phase
|
||||
// 25). The snapshot carries the unchanged `current_turn` because no
|
||||
// new turn has been produced; lobby uses the turn number to anchor
|
||||
// the `game.paused` idempotency key.
|
||||
//
|
||||
// The call is best-effort: lobby errors are returned to the caller
|
||||
// (the scheduler tick) so the warn-level logging stays in one place.
|
||||
// A missing runtime cache entry (e.g. the row was just removed by
|
||||
// the reconciler) collapses into a silent no-op.
|
||||
func (s *Service) publishFailureSnapshot(ctx context.Context, gameID uuid.UUID, runtimeStatus string) error {
|
||||
if s.deps.Lobby == nil {
|
||||
return nil
|
||||
}
|
||||
rec, ok := s.deps.Cache.GetRuntime(gameID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return s.deps.Lobby.OnRuntimeSnapshot(ctx, gameID, LobbySnapshot{
|
||||
CurrentTurn: rec.CurrentTurn,
|
||||
RuntimeStatus: runtimeStatus,
|
||||
EngineHealth: "down",
|
||||
ObservedAt: s.deps.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// transitionRuntimeStatus updates the status / engine_health columns
|
||||
// and refreshes the cache.
|
||||
func (s *Service) transitionRuntimeStatus(ctx context.Context, gameID uuid.UUID, status, health string) (RuntimeRecord, error) {
|
||||
|
||||
@@ -60,6 +60,10 @@ func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||
return
|
||||
}
|
||||
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||
@@ -105,6 +109,10 @@ func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
||||
return
|
||||
}
|
||||
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
||||
@@ -257,6 +265,12 @@ func respondGameProxyError(c *gin.Context, logger *zap.Logger, op string, ctx co
|
||||
switch {
|
||||
case errors.Is(err, runtime.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game")
|
||||
case errors.Is(err, runtime.ErrTurnAlreadyClosed):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeTurnAlreadyClosed,
|
||||
"turn already closed; orders are not accepted while the engine is producing")
|
||||
case errors.Is(err, runtime.ErrGamePaused):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeGamePaused,
|
||||
"game is paused; orders are not accepted until it resumes")
|
||||
case errors.Is(err, runtime.ErrConflict):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
||||
default:
|
||||
|
||||
@@ -23,6 +23,22 @@ const (
|
||||
CodeMethodNotAllowed = "method_not_allowed"
|
||||
CodeInternalError = "internal_error"
|
||||
CodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// CodeTurnAlreadyClosed marks a user-games command or order rejection
|
||||
// caused by the backend's turn-cutoff guard: the request arrived
|
||||
// after the active turn started generating (runtime status
|
||||
// `generation_in_progress` / `generation_failed` / `engine_unreachable`)
|
||||
// and the engine no longer accepts writes for the closing turn. The
|
||||
// caller is expected to wait for the next `game.turn.ready` push and
|
||||
// resubmit against the new turn.
|
||||
CodeTurnAlreadyClosed = "turn_already_closed"
|
||||
|
||||
// CodeGamePaused marks a user-games command or order rejection caused
|
||||
// by the lobby-side game lifecycle: the game is in `paused`,
|
||||
// `finished`, or any other status that does not accept writes. The
|
||||
// caller is expected to wait for the game to resume before
|
||||
// resubmitting.
|
||||
CodeGamePaused = "game_paused"
|
||||
)
|
||||
|
||||
// Body stores the inner `error` object of the standard envelope.
|
||||
|
||||
@@ -2314,9 +2314,10 @@ components:
|
||||
type: string
|
||||
description: |
|
||||
Stable machine-readable failure marker. The closed set is
|
||||
`not_implemented`, `invalid_request`, `unauthorized`, `not_found`,
|
||||
`conflict`, `method_not_allowed`, `internal_error`,
|
||||
`service_unavailable`.
|
||||
`not_implemented`, `invalid_request`, `unauthorized`,
|
||||
`forbidden`, `not_found`, `conflict`, `method_not_allowed`,
|
||||
`internal_error`, `service_unavailable`,
|
||||
`turn_already_closed`, `game_paused`.
|
||||
enum:
|
||||
- not_implemented
|
||||
- invalid_request
|
||||
@@ -2327,6 +2328,8 @@ components:
|
||||
- method_not_allowed
|
||||
- internal_error
|
||||
- service_unavailable
|
||||
- turn_already_closed
|
||||
- game_paused
|
||||
message:
|
||||
type: string
|
||||
description: Human-readable client-safe failure description.
|
||||
|
||||
+15
-3
@@ -785,9 +785,21 @@ Future scale-out hooks (not in MVP):
|
||||
- **runtime snapshot** — engine-status read materialised into the lobby's
|
||||
denormalised view: `current_turn`, `runtime_status`,
|
||||
`engine_health_summary`, `player_turn_stats`.
|
||||
- **turn cutoff** — the `running → generation_in_progress` CAS transition
|
||||
that closes the command window. Commands arriving after the CAS are
|
||||
rejected.
|
||||
- **turn cutoff** — the `running → generation_in_progress` runtime-status
|
||||
flip performed by `backend/internal/runtime/scheduler.go` before each
|
||||
engine `/admin/turn` call. Commands and orders arriving while the
|
||||
flag is set are rejected by the user-games handlers with HTTP 409
|
||||
`turn_already_closed`. The matching reopening flip
|
||||
(`generation_in_progress → running`) happens on a successful tick;
|
||||
a failing tick instead drives the lobby to `paused` and fans out
|
||||
`game.paused` (FUNCTIONAL.md §6.3, §6.5).
|
||||
- **auto-pause** — the lobby reaction to a failed runtime snapshot
|
||||
(`engine_unreachable` / `generation_failed`): the game flips
|
||||
`running → paused`, the order handlers refuse new submits with
|
||||
HTTP 409 `game_paused`, and `lobby.publishGamePaused` fans out the
|
||||
push event. Only an admin `/resume` followed by a successful tick
|
||||
recovers the game; the UI relies on the next `game.turn.ready` to
|
||||
clear the paused banner.
|
||||
- **outbox** — the durable queue of pending mail rows in
|
||||
`mail_deliveries`, drained by the mail worker.
|
||||
- **freshness window** — the symmetric ±5-minute interval around server
|
||||
|
||||
+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
|
||||
semantics live in the engine.
|
||||
|
||||
### 6.3 Turn cutoff
|
||||
### 6.3 Turn cutoff and auto-pause
|
||||
|
||||
A running game continuously alternates between a command-accepting
|
||||
window and a generation phase. The transition `running →
|
||||
generation_in_progress` is the cutoff: any command or order that
|
||||
arrives after the cutoff is rejected by backend before forwarding,
|
||||
because the engine no longer accepts writes for the closing turn.
|
||||
After generation finishes, backend re-opens the window for the next
|
||||
turn.
|
||||
window and a generation phase, driven by the cron expression stored
|
||||
in `runtime_records.turn_schedule`. The backend scheduler
|
||||
(`backend/internal/runtime/scheduler.go`) wraps each engine
|
||||
`/admin/turn` call between two `runtime_status` flips:
|
||||
|
||||
- Before the engine call: `running → generation_in_progress`.
|
||||
The user-games command/order handlers
|
||||
(`backend/internal/server/handlers_user_games.go`) consult the
|
||||
per-game runtime record on every request and reject with
|
||||
HTTP 409 + `code = turn_already_closed` while the runtime sits in
|
||||
`generation_in_progress`. The error envelope mirrors backend's
|
||||
standard `httperr` shape: `{"error": {"code":
|
||||
"turn_already_closed", "message": "..."}}`.
|
||||
- After a successful tick: `generation_in_progress → running`.
|
||||
The order window re-opens for the new turn and the next
|
||||
scheduled tick continues normally.
|
||||
- After a failed tick (`engine_unreachable` /
|
||||
`generation_failed`): the lobby's `OnRuntimeSnapshot` flips the
|
||||
game from `running` to `paused` and publishes a `game.paused`
|
||||
push event (see §6.5). The order handlers reject with HTTP 409
|
||||
+ `code = game_paused` until an admin resume succeeds.
|
||||
|
||||
`force-next-turn` (admin) schedules a one-shot extra tick that
|
||||
advances the next scheduled turn by one cron step.
|
||||
advances the next scheduled turn by one cron step; the same
|
||||
status-flip and rejection rules apply.
|
||||
|
||||
Clients distinguish the two rejections by `code`:
|
||||
`turn_already_closed` means "wait for the next `game.turn.ready`
|
||||
and resubmit", whereas `game_paused` means "wait for an admin
|
||||
resume". The web client implements both reactions in
|
||||
`ui/docs/sync-protocol.md`.
|
||||
|
||||
### 6.4 Reports
|
||||
|
||||
@@ -672,13 +694,24 @@ runtime status, per-player stats). The engine's "game finished"
|
||||
report drives the `running → finished` transition ([Section 3.5](#35-cancellation-and-finish))
|
||||
and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)).
|
||||
|
||||
Among the `game.*` notification kinds, `game.turn.ready` is wired:
|
||||
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
|
||||
emits one intent per advancing `current_turn`, addressed to every
|
||||
active membership of the game, with idempotency key
|
||||
`turn-ready:<game_id>:<turn>` and JSON payload `{game_id, turn}`. The
|
||||
catalog routes the intent through the push channel only; email is
|
||||
deliberately omitted to avoid per-turn spam.
|
||||
Among the `game.*` notification kinds, `game.turn.ready` and
|
||||
`game.paused` are wired:
|
||||
|
||||
- `game.turn.ready` —
|
||||
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
|
||||
emits one intent per advancing `current_turn`, addressed to every
|
||||
active membership of the game, with idempotency key
|
||||
`turn-ready:<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`,
|
||||
`game.finished`) and `mail.dead_lettered` are reserved without a
|
||||
|
||||
+50
-14
@@ -653,17 +653,40 @@ Backend не парсит содержимое payload команд или пр
|
||||
FB-форму только чтобы транскодировать wire-формат; per-command-
|
||||
семантика живёт в движке.
|
||||
|
||||
### 6.3 Окно хода
|
||||
### 6.3 Окно хода и auto-pause
|
||||
|
||||
Запущенная игра постоянно чередуется между окном приёма команд
|
||||
и фазой генерации. Переход `running → generation_in_progress` —
|
||||
cutoff: любая команда или приказ, пришедшие после cutoff,
|
||||
отклоняются backend до форварда, потому что движок больше не
|
||||
принимает запись для закрывающегося хода. После окончания
|
||||
генерации backend заново открывает окно для следующего хода.
|
||||
и фазой генерации, управляемой cron-выражением из
|
||||
`runtime_records.turn_schedule`. Backend-планировщик
|
||||
(`backend/internal/runtime/scheduler.go`) оборачивает каждый
|
||||
engine `/admin/turn` двумя `runtime_status`-флипами:
|
||||
|
||||
- Перед engine-вызовом: `running → generation_in_progress`.
|
||||
User-games-handler'ы команд/приказов
|
||||
(`backend/internal/server/handlers_user_games.go`) на каждом
|
||||
запросе сверяются с per-game runtime-записью и отклоняют с
|
||||
HTTP 409 + `code = turn_already_closed`, пока runtime в
|
||||
`generation_in_progress`. Тело ошибки — стандартный
|
||||
`httperr`-конверт: `{"error": {"code": "turn_already_closed",
|
||||
"message": "..."}}`.
|
||||
- После успешного тика: `generation_in_progress → running`.
|
||||
Окно приказов открывается на новый ход, следующий тик идёт
|
||||
как обычно.
|
||||
- После провалившегося тика (`engine_unreachable` /
|
||||
`generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
|
||||
`running → paused` и публикует push-эвент `game.paused`
|
||||
(см. §6.5). Order-handler'ы отклоняют запросы с HTTP 409 +
|
||||
`code = game_paused`, пока админ не выполнит resume.
|
||||
|
||||
`force-next-turn` (admin) планирует one-shot-доп-тик, который
|
||||
сдвигает следующий запланированный ход на один cron-шаг.
|
||||
сдвигает следующий запланированный ход на один cron-шаг; те же
|
||||
правила status-flip и отклонения применимы.
|
||||
|
||||
Клиенты различают два варианта отказа по `code`:
|
||||
`turn_already_closed` — «дождись следующего `game.turn.ready` и
|
||||
отправь ещё раз», `game_paused` — «дождись resume администратором».
|
||||
Web-клиент реализует оба сценария согласно
|
||||
`ui/docs/sync-protocol.md`.
|
||||
|
||||
### 6.4 Отчёты
|
||||
|
||||
@@ -690,13 +713,26 @@ status, per-player-stats). Engine-отчёт "game finished" гонит
|
||||
([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name
|
||||
Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)).
|
||||
|
||||
Из `game.*`-видов уведомлений подключён `game.turn.ready`:
|
||||
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
|
||||
выпускает один intent на каждое увеличение `current_turn`, адресуя
|
||||
его всем активным membership-ам игры, с idempotency-ключом
|
||||
`turn-ready:<game_id>:<turn>` и JSON-payload-ом `{game_id, turn}`.
|
||||
Каталог направляет intent только в push-канал; email-фан-аут
|
||||
сознательно опущен, чтобы избежать спама на каждом ходе.
|
||||
Из `game.*`-видов уведомлений подключены `game.turn.ready` и
|
||||
`game.paused`:
|
||||
|
||||
- `game.turn.ready` —
|
||||
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
|
||||
выпускает один intent на каждое увеличение `current_turn`,
|
||||
адресуя его всем активным membership-ам игры, с
|
||||
idempotency-ключом `turn-ready:<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.finished`) и `mail.dead_lettered` зарезервированы без поставщика;
|
||||
|
||||
@@ -385,6 +385,12 @@ The current direct `Gateway -> User` self-service boundary uses that pattern:
|
||||
- business error projection:
|
||||
- gateway `result_code`
|
||||
- FlatBuffers error payload mirroring User Service `code` and `message`
|
||||
- User Service `code` values pass through verbatim as `result_code`
|
||||
via `projectUserBackendError`; known non-`ok` codes that clients
|
||||
branch on include `turn_already_closed` (Phase 25 turn cutoff,
|
||||
HTTP 409 from `Orders` / `Commands` while the runtime is in
|
||||
`generation_in_progress`) and `game_paused` (Phase 25 auto-pause,
|
||||
HTTP 409 while the game is in `paused` / `finished` / `removed`).
|
||||
|
||||
The request envelope version literal is `v1`.
|
||||
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
|
||||
|
||||
+120
-26
@@ -2671,43 +2671,137 @@ Targeted tests (delivered):
|
||||
`game.turn.ready` frame surfaces the toast, manual dismiss
|
||||
clears it).
|
||||
|
||||
## Phase 25. Sync Protocol — Order Queue, Retry, Conflict
|
||||
## Phase 25. Sync Protocol — Turn Cutoff, Conflict, Auto-Pause
|
||||
|
||||
Status: pending.
|
||||
Status: in progress.
|
||||
|
||||
Goal: make the order draft survive network failures and turn cutoffs
|
||||
gracefully, with explicit user feedback on conflicts.
|
||||
Goal: make the order draft survive transient connectivity issues
|
||||
**and** the real turn-cutoff machinery, with explicit user feedback
|
||||
on conflicts and on admin-pause states. The phase is intentionally
|
||||
cross-module: the UI side leans on a backend turn-cutoff guard and
|
||||
auto-pause that did not exist before; both land together so the
|
||||
contract is end-to-end.
|
||||
|
||||
Artifacts:
|
||||
Decisions baked in during implementation:
|
||||
|
||||
- `ui/frontend/src/sync/order-queue.ts` send loop: on disconnect, hold
|
||||
the most recent submit; on reconnect, retry once; on persistent
|
||||
failure, surface error to the order tab
|
||||
- conflict detection: if the server returns `turn_already_closed` for
|
||||
a submit, mark the entire draft as `conflict` and surface a
|
||||
`Turn N closed before your order was accepted. Edit and resubmit.`
|
||||
banner in the order tab
|
||||
- topic doc `ui/docs/sync-protocol.md` covering queue semantics,
|
||||
retry budgets, and conflict UX
|
||||
- Turn-cutoff enforcement lives in `backend` (not in `game-engine`).
|
||||
The scheduler flips `runtime_status` to `generation_in_progress`
|
||||
before each engine tick and back to `running` after; the
|
||||
user-games handlers reject every command/order in
|
||||
non-running runtime states.
|
||||
- A failed engine tick auto-pauses the game (`running → paused`)
|
||||
through `lobby.OnRuntimeSnapshot`, and the lobby publishes a
|
||||
matching `game.paused` push event. Admin resume remains the
|
||||
only way out of `paused`.
|
||||
- The wire-level error codes are `turn_already_closed` (cutoff
|
||||
conflict) and `game_paused` (paused / starting / finished / removed).
|
||||
Gateway carries them through `projectUserBackendError` unchanged.
|
||||
- The UI draft store delegates to a new `OrderQueue` (single-slot
|
||||
pending, single retry on reconnect via `onOnline` callback). On
|
||||
`game.turn.ready` after a conflict / pause, the layout calls
|
||||
`OrderDraftStore.resetForNewTurn` which wipes the draft and
|
||||
re-hydrates from the server for the new turn (old commands are
|
||||
preserved server-side and can be read back via
|
||||
`user.games.order.get?turn=N`).
|
||||
|
||||
Dependencies: Phases 14, 24.
|
||||
Backend artifacts:
|
||||
|
||||
- `backend/internal/notification/catalog.go`: new
|
||||
`KindGamePaused = "game.paused"` and `catalog`/`SupportedKinds`
|
||||
entries; matching `NotificationGamePaused` constant in
|
||||
`backend/internal/lobby/lobby.go`; CHECK-constraint widened in
|
||||
`backend/internal/postgres/migrations/00001_init.sql`.
|
||||
- `backend/internal/lobby/runtime_hooks.go`:
|
||||
`nextStatusFromSnapshot` flips `running → paused` on
|
||||
`engine_unreachable` / `generation_failed`; new
|
||||
`publishGamePaused` mirrors `publishTurnReady`, idempotency key
|
||||
`paused:<game_id>:<turn>`, payload `{game_id, turn, reason}`.
|
||||
- `backend/internal/runtime/scheduler.go`: `tick` wraps the engine
|
||||
call with `generation_in_progress` / `running` flips and forwards
|
||||
failure snapshots to lobby through
|
||||
`Service.publishFailureSnapshot`.
|
||||
- `backend/internal/runtime/service.go`: `CheckOrdersAccept` plus
|
||||
the pure `OrdersAcceptStatus` helper used by both `Orders` and
|
||||
`Commands` user-games handlers.
|
||||
- `backend/internal/server/httperr/httperr.go`: new
|
||||
`CodeTurnAlreadyClosed`, `CodeGamePaused`; openapi.yaml
|
||||
`ErrorBody.code` enum extended.
|
||||
- `backend/internal/server/handlers_user_games.go`:
|
||||
`requireOrdersOpen` runs before forwarding, maps sentinels to
|
||||
HTTP 409 + the matching code.
|
||||
|
||||
UI artifacts:
|
||||
|
||||
- `ui/frontend/src/sync/order-queue.svelte.ts` (new) — `OrderQueue`
|
||||
class with offline detection, classification of
|
||||
`turn_already_closed` / `game_paused`, dependency-injected
|
||||
online probe + event listeners. Pure-function helper
|
||||
`classifyResult` reused from tests.
|
||||
- `ui/frontend/src/sync/order-types.ts` — `CommandStatus` gains
|
||||
`conflict`.
|
||||
- `ui/frontend/src/sync/order-draft.svelte.ts` — wires
|
||||
`OrderQueue` through `runSync`, adds `conflict` / `paused` /
|
||||
`offline` to `SyncStatus`, plus `conflictBanner` /
|
||||
`pausedBanner` runes, `markPaused`, `resetForNewTurn`,
|
||||
`clearConflictForMutation`, sticky-`paused` guard in
|
||||
`hydrateFromServer`. `bindClient(client, { getCurrentTurn })`
|
||||
lets the conflict banner interpolate the turn number.
|
||||
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — renders
|
||||
conflict / paused banners and the new `conflict` per-row badge;
|
||||
status bar carries the offline / conflict / paused copy.
|
||||
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new keys for
|
||||
`sync.{offline,conflict,paused}`, `conflict.banner`
|
||||
(with `{turn}` interpolation) plus `banner_no_turn` fallback,
|
||||
`paused.banner`, `status.conflict`.
|
||||
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
|
||||
subscribes to `game.paused`; `game.turn.ready` handler now
|
||||
triggers `resetForNewTurn` when the prior `syncStatus` was
|
||||
`conflict` / `paused`. `bindClient` is invoked with
|
||||
`getCurrentTurn: () => gameState.currentTurn`.
|
||||
- `ui/docs/sync-protocol.md` (new) — send-loop semantics, retry
|
||||
budget, conflict and paused UX, recovery paths.
|
||||
- `ui/docs/order-composer.md` — stale Phase 25 paragraph
|
||||
replaced with a pointer to the new topic doc; state-machine
|
||||
diagram extended with the `conflict` transition.
|
||||
|
||||
Dependencies: Phases 14, 24; backend notification / lobby /
|
||||
runtime modules.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- submitting an order while offline queues it and submits successfully
|
||||
on reconnect;
|
||||
- a turn cutoff between draft and submit produces a visible conflict
|
||||
banner with no data loss;
|
||||
- the order tab clearly distinguishes `draft`, `submitting`,
|
||||
`accepted`, `rejected`, `conflict` states per command.
|
||||
- submitting an order while offline queues it and submits
|
||||
successfully on reconnect (one attempt on the next `online`
|
||||
event, no inline retry storm);
|
||||
- a turn cutoff between draft and submit produces a visible
|
||||
conflict banner with the turn number; the local draft is
|
||||
preserved until the next `game.turn.ready`, then the layout
|
||||
wipes it and re-hydrates from the server for `turn = N+1`;
|
||||
- a runtime failure during generation flips the game into
|
||||
`paused`, emits `game.paused`, and the order tab shows the
|
||||
pause banner; submits are blocked until the next
|
||||
`game.turn.ready` clears the state;
|
||||
- the order tab clearly distinguishes `draft`, `valid`,
|
||||
`invalid`, `submitting`, `applied`, `rejected`, and
|
||||
`conflict` states per command.
|
||||
|
||||
Targeted tests:
|
||||
|
||||
- Vitest unit tests for `order-queue` covering all state transitions;
|
||||
- Playwright e2e: simulate network drop using Playwright's offline
|
||||
mode, submit an order, restore network, confirm submission;
|
||||
- regression test: force a turn cutoff during submit, assert conflict
|
||||
banner appears.
|
||||
- Backend: `runtime_hooks_unit_test.go` for
|
||||
`nextStatusFromSnapshot`, `orders_accept_test.go` for the
|
||||
per-record decision, plus existing testcontainer-backed
|
||||
`runtime_hooks_test.go` covering the published intent. Catalog
|
||||
/ event tests extended with `game.paused`.
|
||||
- UI Vitest: `tests/order-queue.test.ts` (classification +
|
||||
offline plumbing), extended `tests/order-draft.test.ts`
|
||||
(conflict marks commands, mutation clears banner, pause
|
||||
blocks sync, offline holds + flushes on `online`,
|
||||
`resetForNewTurn` re-hydrates), extended
|
||||
`tests/order-tab.test.ts` (banner DOM + sync-status
|
||||
attribute), extended `tests/events.test.ts` (`game.paused`
|
||||
dispatch).
|
||||
- Playwright e2e: `tests/e2e/order-sync.spec.ts` — conflict
|
||||
banner on `turn_already_closed` reply and paused banner on
|
||||
the signed `game.paused` frame.
|
||||
|
||||
## Phase 26. History Mode
|
||||
|
||||
|
||||
@@ -36,11 +36,18 @@ entry by `cmdId`. Successfully applied entries stay visible in
|
||||
the draft (the player keeps composing until turn cutoff);
|
||||
rejected entries stay until the player edits or removes them.
|
||||
|
||||
Phase 25 is reserved for one extension on top of this: per-line
|
||||
sequencing if a future use case needs to submit commands
|
||||
individually rather than in one batch. The wire shape is already
|
||||
flexible enough — the response carries an array of results — so
|
||||
Phase 25 only changes the client-side iteration policy.
|
||||
Phase 25 layers a transport-level policy on top of this baseline
|
||||
without changing the batch semantics. The submit pipeline now
|
||||
goes through `OrderQueue` (see
|
||||
[`sync-protocol.md`](sync-protocol.md)): the queue holds the
|
||||
submit while the browser is offline, classifies
|
||||
`turn_already_closed` and `game_paused` server replies into
|
||||
matching banners on the order tab, and exits the loop on the
|
||||
sticky states so a stream of mutations does not re-elicit the
|
||||
same gateway reply. Recovery from a `conflict` or `paused`
|
||||
banner happens on the next `game.turn.ready` push frame via
|
||||
`OrderDraftStore.resetForNewTurn`, which clears the local draft
|
||||
and re-hydrates from the server for the new turn.
|
||||
|
||||
## Local-validation invariant
|
||||
|
||||
@@ -63,8 +70,10 @@ submit pipeline filters the draft to `valid` entries only — any
|
||||
|
||||
```text
|
||||
draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
|
||||
╲ │ ╲
|
||||
╲──validate──▶ invalid ╲──nack──▶ rejected
|
||||
╲ │ │ ╲
|
||||
╲──validate──▶ invalid │ ╲──nack──▶ rejected
|
||||
│
|
||||
╲────turn_already_closed──▶ conflict
|
||||
```
|
||||
|
||||
Transitions:
|
||||
@@ -76,6 +85,14 @@ Transitions:
|
||||
the draft and sends it to the gateway.
|
||||
- **`submitting → applied` / `submitting → rejected`**: the gateway
|
||||
responded; the entry is no longer in flight.
|
||||
- **`submitting → conflict`** (Phase 25): the gateway returned
|
||||
`resultCode = "turn_already_closed"`. The order tab surfaces a
|
||||
banner above the command list. Any subsequent mutation
|
||||
re-validates the conflict row back to `valid` / `invalid`; a
|
||||
matching `game.turn.ready` push frame triggers
|
||||
`resetForNewTurn`, which wipes the draft entirely. See
|
||||
[`sync-protocol.md`](sync-protocol.md) for the full state
|
||||
table and recovery paths.
|
||||
|
||||
Phase 14 lands the local validators (`draft → valid | invalid`),
|
||||
the submit pipeline (`valid → submitting → applied | rejected`),
|
||||
|
||||
@@ -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.synced": "synced with server",
|
||||
"game.sidebar.order.sync.error": "sync failed: {message}",
|
||||
"game.sidebar.order.sync.offline": "queued — offline, will retry on reconnect",
|
||||
"game.sidebar.order.sync.conflict": "turn closed before submit",
|
||||
"game.sidebar.order.sync.paused": "game paused — orders disabled",
|
||||
"game.sidebar.order.sync.retry": "retry",
|
||||
"game.sidebar.order.conflict.banner": "Turn {turn} closed before your order was accepted. Edit and resubmit.",
|
||||
"game.sidebar.order.conflict.banner_no_turn": "Turn closed before your order was accepted. Edit and resubmit.",
|
||||
"game.sidebar.order.paused.banner": "Game paused. Orders are not accepted until it resumes.",
|
||||
"game.sidebar.order.status.draft": "draft",
|
||||
"game.sidebar.order.status.valid": "valid",
|
||||
"game.sidebar.order.status.invalid": "invalid",
|
||||
"game.sidebar.order.status.submitting": "submitting",
|
||||
"game.sidebar.order.status.applied": "applied",
|
||||
"game.sidebar.order.status.rejected": "rejected",
|
||||
"game.sidebar.order.status.conflict": "conflict",
|
||||
"game.sidebar.order.label.placeholder": "{label}",
|
||||
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
|
||||
"game.sidebar.order.label.planet_production": "set production on planet {planet} → {target}",
|
||||
|
||||
@@ -129,13 +129,20 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.sidebar.order.sync.in_flight": "синхронизация…",
|
||||
"game.sidebar.order.sync.synced": "сохранено на сервере",
|
||||
"game.sidebar.order.sync.error": "ошибка синхронизации: {message}",
|
||||
"game.sidebar.order.sync.offline": "очередь — нет связи, повторим при восстановлении",
|
||||
"game.sidebar.order.sync.conflict": "ход закрылся до отправки",
|
||||
"game.sidebar.order.sync.paused": "игра на паузе — приказы не принимаются",
|
||||
"game.sidebar.order.sync.retry": "повторить",
|
||||
"game.sidebar.order.conflict.banner": "Ход {turn} закрылся до того, как приказ был принят. Отредактируй и отправь ещё раз.",
|
||||
"game.sidebar.order.conflict.banner_no_turn": "Ход закрылся до того, как приказ был принят. Отредактируй и отправь ещё раз.",
|
||||
"game.sidebar.order.paused.banner": "Игра на паузе. Приказы не принимаются, пока она не возобновится.",
|
||||
"game.sidebar.order.status.draft": "черновик",
|
||||
"game.sidebar.order.status.valid": "готова",
|
||||
"game.sidebar.order.status.invalid": "ошибка",
|
||||
"game.sidebar.order.status.submitting": "отправка",
|
||||
"game.sidebar.order.status.applied": "принята",
|
||||
"game.sidebar.order.status.rejected": "отклонена",
|
||||
"game.sidebar.order.status.conflict": "конфликт",
|
||||
"game.sidebar.order.label.placeholder": "{label}",
|
||||
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
|
||||
"game.sidebar.order.label.planet_production": "сменить производство планеты {planet} → {target}",
|
||||
|
||||
@@ -37,6 +37,7 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
||||
submitting: "game.sidebar.order.status.submitting",
|
||||
applied: "game.sidebar.order.status.applied",
|
||||
rejected: "game.sidebar.order.status.rejected",
|
||||
conflict: "game.sidebar.order.status.conflict",
|
||||
};
|
||||
|
||||
function describe(cmd: OrderCommand): string {
|
||||
@@ -152,6 +153,32 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
||||
|
||||
<section class="tool" data-testid="sidebar-tool-order">
|
||||
<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}
|
||||
<p class="empty" data-testid="order-empty">
|
||||
{i18n.t("game.sidebar.empty.order")}
|
||||
@@ -202,6 +229,12 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
||||
{i18n.t("game.sidebar.order.sync.error", {
|
||||
message: draft.syncError ?? "",
|
||||
})}
|
||||
{:else if draft.syncStatus === "offline"}
|
||||
{i18n.t("game.sidebar.order.sync.offline")}
|
||||
{:else if draft.syncStatus === "conflict"}
|
||||
{i18n.t("game.sidebar.order.sync.conflict")}
|
||||
{:else if draft.syncStatus === "paused"}
|
||||
{i18n.t("game.sidebar.order.sync.paused")}
|
||||
{:else}
|
||||
{i18n.t("game.sidebar.order.sync.idle")}
|
||||
{/if}
|
||||
@@ -286,6 +319,27 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
||||
color: #6d8cff;
|
||||
border-color: #2f3f6d;
|
||||
}
|
||||
.status-conflict {
|
||||
color: #d99a4b;
|
||||
border-color: #6d4a2f;
|
||||
}
|
||||
.banner {
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.banner-conflict {
|
||||
color: #f1bf78;
|
||||
background: #2a1f10;
|
||||
border: 1px solid #6d4a2f;
|
||||
}
|
||||
.banner-paused {
|
||||
color: #d4d4d4;
|
||||
background: #1a1f2a;
|
||||
border: 1px solid #2f3f55;
|
||||
}
|
||||
.delete {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
@@ -317,6 +371,15 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
||||
.sync-syncing {
|
||||
color: #6d8cff;
|
||||
}
|
||||
.sync-offline {
|
||||
color: #b9a566;
|
||||
}
|
||||
.sync-conflict {
|
||||
color: #d99a4b;
|
||||
}
|
||||
.sync-paused {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
.sync-retry {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -229,11 +229,13 @@ fresh.
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
// `unsubTurnReady` carries the `eventStream.on(...)` disposer for
|
||||
// the game-scoped turn-ready handler. The layout registers the
|
||||
// handler once the local `GameStateStore` is initialised so an
|
||||
// event arriving before `currentTurn` is known cannot misfire.
|
||||
// `unsubTurnReady` / `unsubGamePaused` carry the
|
||||
// `eventStream.on(...)` disposers for the game-scoped push
|
||||
// handlers. The layout registers them once the local
|
||||
// `GameStateStore` is initialised so an event arriving before
|
||||
// `currentTurn` is known cannot misfire.
|
||||
let unsubTurnReady: (() => void) | null = null;
|
||||
let unsubGamePaused: (() => void) | null = null;
|
||||
const turnReadyDecoder = new TextDecoder("utf-8");
|
||||
|
||||
function parseTurnReadyPayload(
|
||||
@@ -261,6 +263,27 @@ fresh.
|
||||
}
|
||||
}
|
||||
|
||||
function parseGamePausedPayload(
|
||||
event: VerifiedEvent,
|
||||
): { gameId: string; reason: string } | null {
|
||||
try {
|
||||
const text = turnReadyDecoder.decode(event.payloadBytes);
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return null;
|
||||
}
|
||||
const record = json as Record<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;
|
||||
|
||||
$effect(() => {
|
||||
@@ -340,20 +363,42 @@ fresh.
|
||||
// while `gameState.init` is still in flight is not
|
||||
// dropped by the singleton stream. `markPendingTurn`
|
||||
// already protects against turns that do not advance
|
||||
// past the current snapshot.
|
||||
// past the current snapshot. Phase 25: a turn-ready
|
||||
// frame arriving while the draft is in `conflict` or
|
||||
// `paused` state also resets the draft and rehydrates
|
||||
// from the server for the new turn — the old commands
|
||||
// became history at the cutoff.
|
||||
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
||||
const parsed = parseTurnReadyPayload(event);
|
||||
if (parsed === null || parsed.gameId !== gameId) {
|
||||
return;
|
||||
}
|
||||
gameState.markPendingTurn(parsed.turn);
|
||||
if (
|
||||
orderDraft.syncStatus === "conflict" ||
|
||||
orderDraft.syncStatus === "paused"
|
||||
) {
|
||||
void orderDraft.resetForNewTurn({
|
||||
client,
|
||||
turn: parsed.turn,
|
||||
});
|
||||
}
|
||||
});
|
||||
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
||||
const parsed = parseGamePausedPayload(event);
|
||||
if (parsed === null || parsed.gameId !== gameId) {
|
||||
return;
|
||||
}
|
||||
orderDraft.markPaused({ reason: parsed.reason });
|
||||
});
|
||||
await Promise.all([
|
||||
gameState.init({ client, cache, gameId }),
|
||||
orderDraft.init({ cache, gameId }),
|
||||
]);
|
||||
galaxyClient.set(client);
|
||||
orderDraft.bindClient(client);
|
||||
orderDraft.bindClient(client, {
|
||||
getCurrentTurn: () => gameState.currentTurn,
|
||||
});
|
||||
// The server is always polled at game boot — its
|
||||
// stored order may be fresher than the local cache
|
||||
// (e.g. user is on a new device), and an offline
|
||||
@@ -375,6 +420,10 @@ fresh.
|
||||
unsubTurnReady();
|
||||
unsubTurnReady = null;
|
||||
}
|
||||
if (unsubGamePaused !== null) {
|
||||
unsubGamePaused();
|
||||
unsubGamePaused = null;
|
||||
}
|
||||
gameState.dispose();
|
||||
orderDraft.dispose();
|
||||
selection.dispose();
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import type { Cache } from "../platform/store/index";
|
||||
import type { GalaxyClient } from "../api/galaxy-client";
|
||||
import { fetchOrder } from "./order-load";
|
||||
import { OrderQueue, type OrderQueueStartOptions } from "./order-queue.svelte";
|
||||
import {
|
||||
isRelation,
|
||||
isShipGroupCargo,
|
||||
@@ -49,7 +50,47 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
|
||||
|
||||
type Status = "idle" | "ready" | "error";
|
||||
|
||||
export type SyncStatus = "idle" | "syncing" | "synced" | "error";
|
||||
/**
|
||||
* SyncStatus is the order-tab status-bar projection of the auto-sync
|
||||
* pipeline. Phase 14 introduced the `idle`/`syncing`/`synced`/`error`
|
||||
* triplet; Phase 25 adds `offline` (queued during a network outage,
|
||||
* will retry on reconnect), `conflict` (server told us the turn was
|
||||
* already closed; banner pending), and `paused` (game in pause; no
|
||||
* submits until it resumes).
|
||||
*/
|
||||
export type SyncStatus =
|
||||
| "idle"
|
||||
| "syncing"
|
||||
| "synced"
|
||||
| "error"
|
||||
| "offline"
|
||||
| "conflict"
|
||||
| "paused";
|
||||
|
||||
/**
|
||||
* ConflictBanner is the optimistic-conflict UX state displayed
|
||||
* above the order list when a submit landed after the turn cutoff.
|
||||
* `turn` is the value the player thought was open at submit time;
|
||||
* it is read from the `getCurrentTurn` callback supplied to
|
||||
* `bindClient`. The banner is cleared by `resetForNewTurn` (next
|
||||
* `game.turn.ready`) or by any local mutation.
|
||||
*/
|
||||
export interface ConflictBanner {
|
||||
turn: number | null;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PausedBanner is displayed when the server tells us the game is
|
||||
* paused. The banner is cleared by `resetForNewTurn` once the game
|
||||
* resumes (a fresh `game.turn.ready` event).
|
||||
*/
|
||||
export interface PausedBanner {
|
||||
code: string;
|
||||
message: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class OrderDraftStore {
|
||||
commands: OrderCommand[] = $state([]);
|
||||
@@ -61,24 +102,52 @@ export class OrderDraftStore {
|
||||
/**
|
||||
* syncStatus reflects the auto-sync pipeline state for the order
|
||||
* tab status bar:
|
||||
* - `idle` — no sync attempted yet (e.g. fresh draft after
|
||||
* hydration or before the first mutation).
|
||||
* - `syncing` — a `submitOrder` call is in flight.
|
||||
* - `synced` — the last sync succeeded; statuses match the
|
||||
* server's view.
|
||||
* - `error` — the last sync failed (network or non-`ok`); the
|
||||
* next mutation triggers a retry, or the user can
|
||||
* force a re-sync via `forceSync`.
|
||||
* - `idle` — no sync attempted yet (e.g. fresh draft after
|
||||
* hydration or before the first mutation).
|
||||
* - `syncing` — a `submitOrder` call is in flight.
|
||||
* - `synced` — the last sync succeeded; statuses match the
|
||||
* server's view.
|
||||
* - `error` — the last sync failed (network or non-`ok`); the
|
||||
* next mutation triggers a retry, or the user can
|
||||
* force a re-sync via `forceSync`.
|
||||
* - `offline` — the browser is offline; the last submit was
|
||||
* held. A fresh send fires on the next `online`
|
||||
* flip via the queue callback.
|
||||
* - `conflict` — the gateway returned `turn_already_closed`;
|
||||
* the in-flight commands are marked `conflict`
|
||||
* and `conflictBanner` carries the user-facing
|
||||
* copy.
|
||||
* - `paused` — the gateway returned `game_paused` (or a
|
||||
* `game.paused` push frame arrived); no submits
|
||||
* fire until `resetForNewTurn` clears it.
|
||||
*/
|
||||
syncStatus: SyncStatus = $state("idle");
|
||||
syncError: string | null = $state(null);
|
||||
|
||||
/**
|
||||
* conflictBanner is non-null whenever `syncStatus === "conflict"`.
|
||||
* The order tab renders the banner above the command list with
|
||||
* the turn number interpolated; clearing it is the
|
||||
* `resetForNewTurn` / mutation responsibility.
|
||||
*/
|
||||
conflictBanner: ConflictBanner | null = $state(null);
|
||||
|
||||
/**
|
||||
* pausedBanner is non-null whenever `syncStatus === "paused"`.
|
||||
* The order tab renders a pause-specific banner separate from
|
||||
* the conflict path.
|
||||
*/
|
||||
pausedBanner: PausedBanner | null = $state(null);
|
||||
|
||||
private cache: Cache | null = null;
|
||||
private gameId = "";
|
||||
private destroyed = false;
|
||||
private client: GalaxyClient | null = null;
|
||||
private syncing: Promise<void> | null = null;
|
||||
private pending = false;
|
||||
private queue = new OrderQueue();
|
||||
private queueStarted = false;
|
||||
private getCurrentTurn: (() => number) | null = null;
|
||||
|
||||
/**
|
||||
* init loads the persisted draft for `opts.gameId` from `opts.cache`
|
||||
@@ -93,7 +162,9 @@ export class OrderDraftStore {
|
||||
* authoritative read that always overwrites the local cache when
|
||||
* the server has a stored order.
|
||||
*/
|
||||
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
|
||||
async init(
|
||||
opts: { cache: Cache; gameId: string; queue?: OrderQueueStartOptions },
|
||||
): Promise<void> {
|
||||
this.cache = opts.cache;
|
||||
this.gameId = opts.gameId;
|
||||
try {
|
||||
@@ -110,6 +181,7 @@ export class OrderDraftStore {
|
||||
this.status = "error";
|
||||
this.error = err instanceof Error ? err.message : "load failed";
|
||||
}
|
||||
this.startQueue(opts.queue);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,9 +190,18 @@ export class OrderDraftStore {
|
||||
* this after the boot `Promise.all` resolves and before
|
||||
* `hydrateFromServer`, so any mutation that lands afterwards goes
|
||||
* through the network.
|
||||
*
|
||||
* Phase 25: `opts.getCurrentTurn` lets the conflict banner
|
||||
* interpolate the turn number the player was composing for. The
|
||||
* layout passes `() => gameState.currentTurn`; tests may omit it,
|
||||
* in which case the banner falls back to a turn-less template.
|
||||
*/
|
||||
bindClient(client: GalaxyClient): void {
|
||||
bindClient(
|
||||
client: GalaxyClient,
|
||||
opts: { getCurrentTurn?: () => number } = {},
|
||||
): void {
|
||||
this.client = client;
|
||||
this.getCurrentTurn = opts.getCurrentTurn ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +219,14 @@ export class OrderDraftStore {
|
||||
turn: number;
|
||||
}): Promise<void> {
|
||||
if (this.status !== "ready") return;
|
||||
// Phase 25: a `game.paused` push frame may arrive before the
|
||||
// initial hydrate completes (the layout subscribes early to
|
||||
// avoid losing in-flight frames). The pause is stickier than a
|
||||
// freshly-loaded snapshot — keep the banner up and skip the
|
||||
// fetch entirely. A subsequent `resetForNewTurn` (triggered by
|
||||
// `game.turn.ready` after the game resumes) re-runs the
|
||||
// hydration from scratch.
|
||||
if (this.syncStatus === "paused") return;
|
||||
this.client = opts.client;
|
||||
// Guard against placeholder game ids the Phase 10 e2e specs
|
||||
// still use — auto-sync needs a real UUID for the FBS request
|
||||
@@ -152,6 +241,11 @@ export class OrderDraftStore {
|
||||
try {
|
||||
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
|
||||
if (this.destroyed) return;
|
||||
// If `markPaused` landed between the initial syncStatus
|
||||
// flip and the awaited fetch, the pause is the
|
||||
// authoritative state — do not overwrite it with synced.
|
||||
// The fetched commands are still adopted so a later
|
||||
// `resetForNewTurn` can build on top of them.
|
||||
this.commands = fetched.commands;
|
||||
this.updatedAt = fetched.updatedAt;
|
||||
this.recomputeStatuses();
|
||||
@@ -166,11 +260,15 @@ export class OrderDraftStore {
|
||||
}
|
||||
this.statuses = next;
|
||||
await this.persist();
|
||||
this.syncStatus = "synced";
|
||||
if ((this.syncStatus as SyncStatus) !== "paused") {
|
||||
this.syncStatus = "synced";
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
this.syncStatus = "error";
|
||||
this.syncError = err instanceof Error ? err.message : "fetch failed";
|
||||
if ((this.syncStatus as SyncStatus) !== "paused") {
|
||||
this.syncStatus = "error";
|
||||
this.syncError = err instanceof Error ? err.message : "fetch failed";
|
||||
}
|
||||
console.warn("order-draft: server hydration failed", err);
|
||||
}
|
||||
}
|
||||
@@ -207,6 +305,7 @@ export class OrderDraftStore {
|
||||
*/
|
||||
async add(command: OrderCommand): Promise<void> {
|
||||
if (this.status !== "ready") return;
|
||||
this.clearConflictForMutation();
|
||||
const removed: string[] = [];
|
||||
let nextCommands: OrderCommand[];
|
||||
if (command.kind === "setProductionType") {
|
||||
@@ -288,6 +387,7 @@ export class OrderDraftStore {
|
||||
if (this.status !== "ready") return;
|
||||
const next = this.commands.filter((cmd) => cmd.id !== id);
|
||||
if (next.length === this.commands.length) return;
|
||||
this.clearConflictForMutation();
|
||||
this.commands = next;
|
||||
const nextStatuses = { ...this.statuses };
|
||||
delete nextStatuses[id];
|
||||
@@ -310,6 +410,7 @@ export class OrderDraftStore {
|
||||
if (fromIndex < 0 || fromIndex >= length) return;
|
||||
if (toIndex < 0 || toIndex >= length) return;
|
||||
if (fromIndex === toIndex) return;
|
||||
this.clearConflictForMutation();
|
||||
const next = [...this.commands];
|
||||
const [picked] = next.splice(fromIndex, 1);
|
||||
if (picked === undefined) return;
|
||||
@@ -327,10 +428,61 @@ export class OrderDraftStore {
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* markPaused projects an incoming `game.paused` push event into
|
||||
* the store: the order tab shows the pause banner, the auto-sync
|
||||
* loop short-circuits, and any submitting rows revert to `valid`
|
||||
* (the matching engine state is still the old one). The layout
|
||||
* calls this from the `game.paused` subscription. `reason`
|
||||
* carries the raw runtime status published by lobby
|
||||
* (`engine_unreachable` / `generation_failed`); the UI ignores
|
||||
* it today but the payload is preserved for future copy
|
||||
* differentiation.
|
||||
*/
|
||||
markPaused(opts: { reason: string; message?: string }): void {
|
||||
if (this.status !== "ready") return;
|
||||
this.revertSubmittingToValidInternal();
|
||||
this.pausedBanner = {
|
||||
code: "game_paused",
|
||||
message: opts.message ?? "Game paused. Orders are not accepted until it resumes.",
|
||||
reason: opts.reason,
|
||||
};
|
||||
this.syncStatus = "paused";
|
||||
this.syncError = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* resetForNewTurn drops the local draft, clears every Phase 25
|
||||
* banner, and hydrates from the server for the supplied turn.
|
||||
* The layout calls this from the `game.turn.ready` subscription
|
||||
* when the prior `syncStatus` was `conflict` or `paused`. The
|
||||
* effect mirrors a fresh boot: cache wipe → fetch → seed.
|
||||
*/
|
||||
async resetForNewTurn(opts: {
|
||||
client: GalaxyClient;
|
||||
turn: number;
|
||||
}): Promise<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 {
|
||||
this.destroyed = true;
|
||||
this.cache = null;
|
||||
this.client = null;
|
||||
this.getCurrentTurn = null;
|
||||
if (this.queueStarted) {
|
||||
this.queue.stop();
|
||||
this.queueStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
@@ -338,6 +490,16 @@ export class OrderDraftStore {
|
||||
// Same UUID guard as `hydrateFromServer` — placeholder game
|
||||
// ids in test fixtures must not blow up the auto-sync path.
|
||||
if (!isUuid(this.gameId)) return;
|
||||
// Conflict / paused states are sticky: the order tab is
|
||||
// waiting for the next `game.turn.ready` (conflict) or for
|
||||
// the admin to resume (paused). Local mutations clear the
|
||||
// conflict; the layout's `markPaused`/`resetForNewTurn` clear
|
||||
// the pause. Trying to send mid-state would re-elicit the
|
||||
// same gateway reply on every keystroke and overwrite the
|
||||
// banner with the same message.
|
||||
if (this.syncStatus === "conflict" || this.syncStatus === "paused") {
|
||||
return;
|
||||
}
|
||||
if (this.syncing !== null) {
|
||||
this.pending = true;
|
||||
return;
|
||||
@@ -378,45 +540,98 @@ export class OrderDraftStore {
|
||||
this.syncStatus = "syncing";
|
||||
this.syncError = null;
|
||||
|
||||
try {
|
||||
const result = await submitOrder(
|
||||
client,
|
||||
this.gameId,
|
||||
submittable,
|
||||
{ updatedAt: this.updatedAt },
|
||||
);
|
||||
if (this.destroyed) return;
|
||||
if (result.ok) {
|
||||
this.applyResultsInternal(result.results, result.updatedAt);
|
||||
const outcome = await this.queue.send(() =>
|
||||
submitOrder(client, this.gameId, submittable, {
|
||||
updatedAt: this.updatedAt,
|
||||
}),
|
||||
);
|
||||
if (this.destroyed) return;
|
||||
switch (outcome.kind) {
|
||||
case "success": {
|
||||
this.applyResultsInternal(
|
||||
outcome.result.results,
|
||||
outcome.result.updatedAt,
|
||||
);
|
||||
// Even with `result.ok === true` an individual
|
||||
// command may have been rejected by the engine
|
||||
// (e.g. validation passed transcoders but failed
|
||||
// the in-game rule). Surface that as an error in
|
||||
// the sync bar so the player notices and can fix
|
||||
// or remove the offending command.
|
||||
const anyRejected = Array.from(result.results.values()).some(
|
||||
(s) => s === "rejected",
|
||||
);
|
||||
const anyRejected = Array.from(
|
||||
outcome.result.results.values(),
|
||||
).some((s) => s === "rejected");
|
||||
this.syncStatus = anyRejected ? "error" : "synced";
|
||||
this.syncError = anyRejected
|
||||
? "engine rejected one or more commands"
|
||||
: null;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
case "rejected": {
|
||||
this.markRejectedInternal(submittingIds);
|
||||
this.syncStatus = "error";
|
||||
this.syncError = result.message;
|
||||
this.syncError = outcome.failure.message;
|
||||
break;
|
||||
}
|
||||
case "conflict": {
|
||||
this.markConflictInternal(submittingIds);
|
||||
this.conflictBanner = {
|
||||
turn: this.getCurrentTurn?.() ?? null,
|
||||
code: outcome.code,
|
||||
message: outcome.message,
|
||||
};
|
||||
this.syncStatus = "conflict";
|
||||
this.syncError = null;
|
||||
// Stickiness: conflict overrides any pending
|
||||
// mutations until the next `game.turn.ready` or a
|
||||
// local edit clears the banner.
|
||||
return;
|
||||
}
|
||||
case "paused": {
|
||||
this.revertSubmittingToValidInternal();
|
||||
this.pausedBanner = {
|
||||
code: outcome.code,
|
||||
message: outcome.message,
|
||||
reason: outcome.code,
|
||||
};
|
||||
this.syncStatus = "paused";
|
||||
this.syncError = null;
|
||||
return;
|
||||
}
|
||||
case "offline": {
|
||||
this.revertSubmittingToValidInternal();
|
||||
this.syncStatus = "offline";
|
||||
this.syncError = null;
|
||||
return;
|
||||
}
|
||||
case "failed": {
|
||||
this.revertSubmittingToValidInternal();
|
||||
this.syncStatus = "error";
|
||||
this.syncError = outcome.reason;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
this.revertSubmittingToValidInternal();
|
||||
this.syncStatus = "error";
|
||||
this.syncError = err instanceof Error ? err.message : "sync failed";
|
||||
}
|
||||
|
||||
if (!this.pending) return;
|
||||
}
|
||||
}
|
||||
|
||||
private startQueue(opts?: OrderQueueStartOptions): void {
|
||||
if (this.queueStarted) return;
|
||||
this.queue.start({
|
||||
onOnline: () => {
|
||||
if (this.destroyed) return;
|
||||
if (this.syncStatus === "offline") {
|
||||
this.scheduleSync();
|
||||
}
|
||||
},
|
||||
onlineProbe: opts?.onlineProbe,
|
||||
addEventListener: opts?.addEventListener,
|
||||
removeEventListener: opts?.removeEventListener,
|
||||
});
|
||||
this.queueStarted = true;
|
||||
}
|
||||
|
||||
private markSubmittingInternal(ids: string[]): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const id of ids) {
|
||||
@@ -457,6 +672,43 @@ export class OrderDraftStore {
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
private markConflictInternal(ids: string[]): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const id of ids) {
|
||||
next[id] = "conflict";
|
||||
}
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* clearConflictForMutation drops the conflict banner and
|
||||
* re-validates every `conflict`-marked command back to its
|
||||
* pre-submit status. Called from every mutation (`add`,
|
||||
* `remove`, `move`) so the user-driven "Edit and resubmit" flow
|
||||
* works without an extra dismiss step.
|
||||
*/
|
||||
private clearConflictForMutation(): void {
|
||||
if (this.syncStatus !== "conflict" && this.conflictBanner === null) {
|
||||
return;
|
||||
}
|
||||
const next = { ...this.statuses };
|
||||
let mutated = false;
|
||||
for (const cmd of this.commands) {
|
||||
if (next[cmd.id] === "conflict") {
|
||||
next[cmd.id] = validateCommand(cmd);
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (mutated) {
|
||||
this.statuses = next;
|
||||
}
|
||||
this.conflictBanner = null;
|
||||
if (this.syncStatus === "conflict") {
|
||||
this.syncStatus = "idle";
|
||||
this.syncError = null;
|
||||
}
|
||||
}
|
||||
|
||||
private revertSubmittingToValidInternal(): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const cmd of this.commands) {
|
||||
|
||||
@@ -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
|
||||
* it lands in the draft to the moment the server resolves it. The
|
||||
* skeleton stores only the type description; Phase 14 adds the
|
||||
* `valid` / `invalid` transitions driven by local validation, and
|
||||
* Phase 25 introduces `submitting` / `applied` / `rejected` driven
|
||||
* by the submit pipeline.
|
||||
* it lands in the draft to the moment the server resolves it. Phase
|
||||
* 14 adds the `valid` / `invalid` transitions driven by local
|
||||
* validation and the `submitting` / `applied` / `rejected` triplet
|
||||
* driven by the submit pipeline; Phase 25 adds `conflict` for
|
||||
* commands whose submit landed after the turn cutoff
|
||||
* (`turn_already_closed` from the gateway).
|
||||
*
|
||||
* The state machine is:
|
||||
*
|
||||
* draft → valid → submitting → applied
|
||||
* ↘ invalid ↘ rejected
|
||||
* ↘ conflict
|
||||
*
|
||||
* A command is `draft` until local validation has run, then `valid`
|
||||
* or `invalid`. On submit the entry transitions to `submitting`,
|
||||
* then to `applied` or `rejected` once the gateway responds.
|
||||
* then to `applied` / `rejected` / `conflict` once the gateway
|
||||
* responds. A `conflict` row stays in the draft until the next
|
||||
* `game.turn.ready` triggers a `resetForNewTurn`, or the user edits
|
||||
* the draft (any mutation re-validates the conflict back to `valid`
|
||||
* or `invalid`).
|
||||
*/
|
||||
export type CommandStatus =
|
||||
| "draft"
|
||||
@@ -579,4 +585,5 @@ export type CommandStatus =
|
||||
| "invalid"
|
||||
| "submitting"
|
||||
| "applied"
|
||||
| "rejected";
|
||||
| "rejected"
|
||||
| "conflict";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
test("game.paused events dispatch to the matching handler (Phase 25)", async () => {
|
||||
const handler = vi.fn();
|
||||
eventStream.on("game.paused", handler);
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
game_id: "11111111-2222-3333-4444-555555555555",
|
||||
turn: 7,
|
||||
reason: "generation_failed",
|
||||
}),
|
||||
);
|
||||
const event = buildEvent("game.paused", payload);
|
||||
const client = makeRouter(async function* () {
|
||||
yield event;
|
||||
});
|
||||
eventStream.start({
|
||||
core: mockCore(),
|
||||
keypair: mockKeypair(),
|
||||
deviceSessionId: "device-1",
|
||||
gatewayResponsePublicKey: new Uint8Array(32),
|
||||
client,
|
||||
sleep: async () => {},
|
||||
random: () => 0,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
expect(handler.mock.calls[0]?.[0].eventType).toBe("game.paused");
|
||||
eventStream.stop();
|
||||
});
|
||||
|
||||
test("connectionStatus transitions through connecting → connected → idle", async () => {
|
||||
expect(eventStream.connectionStatus).toBe("idle");
|
||||
const event = buildEvent(
|
||||
|
||||
@@ -33,10 +33,25 @@ interface RecordedCall {
|
||||
commandIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* RecordingOutcome enumerates the synthetic server reactions a test
|
||||
* can drive through `recordingClient.setOutcome`. Phase 25 adds the
|
||||
* `turn_already_closed` and `game_paused` codes (the order-queue
|
||||
* classifies them into `conflict` / `paused` outcomes) and `throw`
|
||||
* which lets the test exercise the network-error branch of
|
||||
* `OrderQueue.send`.
|
||||
*/
|
||||
export type RecordingOutcome =
|
||||
| "ok"
|
||||
| "rejected"
|
||||
| "turn_already_closed"
|
||||
| "game_paused"
|
||||
| "throw";
|
||||
|
||||
interface RecordingHandle {
|
||||
client: GalaxyClient;
|
||||
calls: RecordedCall[];
|
||||
setOutcome(outcome: "ok" | "rejected"): void;
|
||||
setOutcome(outcome: RecordingOutcome): void;
|
||||
waitForCalls(n: number): Promise<void>;
|
||||
waitForIdle(): Promise<void>;
|
||||
}
|
||||
@@ -51,11 +66,11 @@ interface RecordingHandle {
|
||||
*/
|
||||
export function recordingClient(
|
||||
gameId: string,
|
||||
initialOutcome: "ok" | "rejected",
|
||||
initialOutcome: RecordingOutcome,
|
||||
options: { delayMs?: number } = {},
|
||||
): RecordingHandle {
|
||||
const calls: RecordedCall[] = [];
|
||||
let outcome: "ok" | "rejected" = initialOutcome;
|
||||
let outcome: RecordingOutcome = initialOutcome;
|
||||
let inFlight = 0;
|
||||
const waiters: (() => void)[] = [];
|
||||
|
||||
@@ -81,21 +96,45 @@ export function recordingClient(
|
||||
if (id !== null) commandIds.push(id);
|
||||
}
|
||||
calls.push({ messageType, commandIds });
|
||||
if (outcome === "ok") {
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: encodeApplied(gameId, commandIds, true),
|
||||
};
|
||||
switch (outcome) {
|
||||
case "ok":
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: encodeApplied(gameId, commandIds, true),
|
||||
};
|
||||
case "turn_already_closed":
|
||||
return {
|
||||
resultCode: "turn_already_closed",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
code: "turn_already_closed",
|
||||
message: "turn closed before submit",
|
||||
}),
|
||||
),
|
||||
};
|
||||
case "game_paused":
|
||||
return {
|
||||
resultCode: "game_paused",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
code: "game_paused",
|
||||
message: "game is paused",
|
||||
}),
|
||||
),
|
||||
};
|
||||
case "throw":
|
||||
throw new Error("network down");
|
||||
default:
|
||||
return {
|
||||
resultCode: "invalid_request",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
code: "validation_failed",
|
||||
message: "rejected by fixture",
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
resultCode: "invalid_request",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
code: "validation_failed",
|
||||
message: "rejected by fixture",
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected messageType ${messageType}`);
|
||||
} finally {
|
||||
@@ -113,7 +152,7 @@ export function recordingClient(
|
||||
return {
|
||||
client,
|
||||
calls,
|
||||
setOutcome(next: "ok" | "rejected") {
|
||||
setOutcome(next: RecordingOutcome) {
|
||||
outcome = next;
|
||||
},
|
||||
async waitForCalls(n: number) {
|
||||
|
||||
@@ -623,3 +623,189 @@ describe("OrderDraftStore auto-sync", () => {
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrderDraftStore Phase 25 conflict / paused / offline", () => {
|
||||
test("turn_already_closed marks the in-flight commands as conflict", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "turn_already_closed");
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
store.bindClient(handle.client, { getCurrentTurn: () => 7 });
|
||||
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
await handle.waitForCalls(1);
|
||||
|
||||
expect(store.syncStatus).toBe("conflict");
|
||||
expect(store.statuses["id-1"]).toBe("conflict");
|
||||
expect(store.conflictBanner).not.toBeNull();
|
||||
expect(store.conflictBanner?.turn).toBe(7);
|
||||
expect(store.conflictBanner?.code).toBe("turn_already_closed");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("mutating after a conflict clears the banner and revalidates", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "turn_already_closed");
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
store.bindClient(handle.client, { getCurrentTurn: () => 3 });
|
||||
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
await handle.waitForCalls(1);
|
||||
expect(store.syncStatus).toBe("conflict");
|
||||
|
||||
// Adding a second command must wipe the conflict banner and
|
||||
// re-validate the prior conflict-marked entry. Auto-sync
|
||||
// re-fires (still seeing turn_already_closed) and the
|
||||
// store ends up back in conflict for the new attempt.
|
||||
handle.setOutcome("ok");
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-2",
|
||||
planetNumber: 2,
|
||||
name: "Mars",
|
||||
});
|
||||
await handle.waitForCalls(2);
|
||||
expect(store.statuses["id-1"]).toBe("applied");
|
||||
expect(store.statuses["id-2"]).toBe("applied");
|
||||
expect(store.syncStatus).toBe("synced");
|
||||
expect(store.conflictBanner).toBeNull();
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("game_paused outcome surfaces the pause banner and locks sync", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "game_paused");
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
store.bindClient(handle.client);
|
||||
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
await handle.waitForCalls(1);
|
||||
|
||||
expect(store.syncStatus).toBe("paused");
|
||||
expect(store.pausedBanner).not.toBeNull();
|
||||
expect(store.statuses["id-1"]).toBe("valid"); // reverted, not in flight
|
||||
|
||||
// While paused, additional mutations should not trigger another
|
||||
// submit — the queue would just hit the same wall.
|
||||
const before = handle.calls.length;
|
||||
store.forceSync();
|
||||
await new Promise<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();
|
||||
});
|
||||
|
||||
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