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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user