ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol
Backend now owns the turn-cutoff and pause guards the order tab relies on: the scheduler flips runtime_status between generation_in_progress and running around every engine tick, a failed tick auto-pauses the game through OnRuntimeSnapshot, and a new game.paused notification kind fans out alongside game.turn.ready. The user-games handlers reject submits with HTTP 409 turn_already_closed or game_paused depending on the runtime state. UI delegates auto-sync to a new OrderQueue: offline detection, single retry on reconnect, conflict / paused classification. OrderDraftStore surfaces conflictBanner / pausedBanner runes, clears them on local mutation or on a game.turn.ready push via resetForNewTurn. The order tab renders the matching banners and the new conflict per-row badge; i18n bundles cover en + ru. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user