ui/phase-14: auto-sync order draft + always GET on boot + header headline

Replaces the manual Submit button with an auto-sync pipeline driven
by `OrderDraftStore`: every successful add / remove / move
coalesces a `submitOrder` call so the engine always mirrors the
local draft. Removing the last command sends an empty cmd[] PUT —
the engine, repo, and rest model now accept that as a valid
"player cleared their draft" state.

`hydrateFromServer` is now invoked unconditionally on game boot so
a fresh device picks up the player's stored order, and the local
cache is overwritten by the server's view (server is the source of
truth).

Header replaces the static "race ?" + turn counter with a single
headline string `<race> @ <game>, turn <n>`, sourced from the
engine's Report.race + the lobby's GameSummary.gameName + the live
turn number, with a `?` fallback while any piece is loading.

Tests:
- engine: empty PUT round-trips, repo round-trips empty Commands
- order-draft: auto-sync sends full draft on every mutation,
  rejected response surfaces error sync status, rapid mutations
  coalesce, server hydration overwrites cache
- order-tab: per-row status flips through the auto-sync lifecycle,
  remove → empty cmd[] PUT, rejected → retry button
- inspector overlay: applied + valid + submitting all participate
  in the optimistic projection
- header: live race / game / turn rendering with fall-back

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 13:34:10 +02:00
parent 68d8607eaa
commit 229c43beb5
26 changed files with 1144 additions and 728 deletions
+27 -12
View File
@@ -67,6 +67,12 @@ export interface GameReport {
mapHeight: number;
planetCount: number;
planets: ReportPlanet[];
/**
* race is the calling player's race name as resolved by the
* engine from the runtime player mapping. Empty when the engine
* has not produced a report yet (boot state).
*/
race: string;
}
export async function fetchGameReport(
@@ -189,6 +195,7 @@ function decodeReport(report: Report): GameReport {
mapHeight: report.height(),
planetCount: report.planetCount(),
planets,
race: report.race() ?? "",
};
}
@@ -212,18 +219,20 @@ export function uuidToHiLo(value: string): [bigint, bigint] {
}
/**
* applyOrderOverlay returns a copy of `report` with every applied or
* still-in-flight (`submitting`) command from `commands` projected on
* top. Phase 14 understands `planetRename` only — every other variant
* passes through. The function is pure: callers re-derive the
* overlay whenever the draft or the report change.
* applyOrderOverlay returns a copy of `report` with every locally-
* valid or still-in-flight or applied command from `commands`
* projected on top. Phase 14 understands `planetRename` only —
* every other variant passes through. The function is pure:
* callers re-derive the overlay whenever the draft or the report
* change.
*
* `statuses` maps command id → status. Entries with `applied` or
* `submitting` participate in the overlay; everything else (`draft`,
* `valid`, `invalid`, `rejected`) is treated as "not yet committed
* by the player" and skipped. This matches the order-composer model:
* the player sees their own committed intent, not their unfinished
* edits.
* `statuses` maps command id → status. Entries with `valid`,
* `submitting`, or `applied` participate in the overlay — together
* they describe "the player's committed intent for this turn":
* locally-valid (auto-sync about to fire), in-flight on the wire,
* or acknowledged by the engine. Entries with `draft`, `invalid`,
* or `rejected` skip the overlay so the player keeps the server's
* (un-renamed) view.
*/
export function applyOrderOverlay(
report: GameReport,
@@ -234,7 +243,13 @@ export function applyOrderOverlay(
let mutatedPlanets: ReportPlanet[] | null = null;
for (const cmd of commands) {
const status = statuses[cmd.id];
if (status !== "applied" && status !== "submitting") continue;
if (
status !== "valid" &&
status !== "submitting" &&
status !== "applied"
) {
continue;
}
if (cmd.kind !== "planetRename") continue;
const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber);
if (idx < 0) continue;