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
+66 -124
View File
@@ -1,19 +1,18 @@
<!--
Order composer tool. Resolves the per-game `OrderDraftStore`,
`GameStateStore`, and `GalaxyClient` from context (all set by
`routes/games/[id]/+layout.svelte`) and renders the local draft as
a vertical list with per-row status, a delete button, and a Submit
button at the bottom.
Order composer tool. Resolves the per-game `OrderDraftStore` from
context (set by `routes/games/[id]/+layout.svelte`) and renders the
local draft as a vertical list with per-row status and a delete
button.
Phase 14 wires the first end-to-end command: clicking Submit calls
`submitOrder` for every entry in `valid` status, flips the in-flight
rows to `submitting`, then merges the per-command verdict back into
the draft once the gateway responds. The optimistic overlay in
`renderedReport` continues to show the player's intent while the
order is in flight, so the inspector and the map reflect the new
name even before the server applies it at turn cutoff.
Phase 14 wires the auto-sync pipeline directly into the draft
store: every successful `add` / `remove` / `move` triggers a
`submitOrder` call so the server always mirrors the local draft.
This view shows the resulting per-command status (`valid`,
`submitting`, `applied`, `rejected`) and a small status bar at the
bottom that surfaces the latest sync result. The earlier explicit
Submit button is gone — there is no separate "send" step anymore.
Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
(Playwright) and via direct store / mocked-client construction
(Vitest).
-->
@@ -24,26 +23,11 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
type GalaxyClientHandle,
} from "$lib/galaxy-client-context.svelte";
import type { CommandStatus, OrderCommand } from "../../sync/order-types";
import { submitOrder } from "../../sync/submit";
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const galaxyClient = getContext<GalaxyClientHandle | undefined>(
GALAXY_CLIENT_CONTEXT_KEY,
);
const statusKeyMap: Record<CommandStatus, TranslationKey> = {
draft: "game.sidebar.order.status.draft",
@@ -54,30 +38,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
rejected: "game.sidebar.order.status.rejected",
};
let submitInFlight = $state(false);
let submitError = $state<string | null>(null);
const submittable = $derived.by(() => {
if (draft === undefined) return [] as OrderCommand[];
return draft.commands.filter(
(cmd) => draft.statuses[cmd.id] === "valid",
);
});
const hasInvalid = $derived.by(() => {
if (draft === undefined) return false;
return draft.commands.some((cmd) => draft.statuses[cmd.id] === "invalid");
});
const submitDisabled = $derived(
draft === undefined ||
galaxyClient === undefined ||
galaxyClient.client === null ||
submitInFlight ||
submittable.length === 0 ||
hasInvalid,
);
function describe(cmd: OrderCommand): string {
switch (cmd.kind) {
case "placeholder":
@@ -95,50 +55,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft";
}
async function submit(): Promise<void> {
if (
draft === undefined ||
galaxyClient === undefined ||
galaxyClient.client === null ||
gameState === undefined
)
return;
if (submittable.length === 0 || hasInvalid) return;
const ids = submittable.map((cmd) => cmd.id);
const snapshot = submittable.slice();
submitInFlight = true;
submitError = null;
draft.markSubmitting(ids);
try {
const result = await submitOrder(
galaxyClient.client,
gameState.gameId,
snapshot,
{ updatedAt: draft.updatedAt },
);
if (result.ok) {
draft.applyResults({
results: result.results,
updatedAt: result.updatedAt,
});
if (gameState !== undefined) {
await gameState.refresh();
}
} else {
draft.markRejected(ids);
submitError = i18n.t("game.sidebar.order.error.batch_failed", {
message: result.message,
});
}
} catch (err) {
draft.revertSubmittingToValid();
submitError =
err instanceof Error ? err.message : "submit failed";
} finally {
submitInFlight = false;
}
}
</script>
<section class="tool" data-testid="sidebar-tool-order">
@@ -177,20 +93,37 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
</li>
{/each}
</ol>
<button
type="button"
class="submit"
data-testid="order-submit"
disabled={submitDisabled}
onclick={() => void submit()}
{/if}
{#if draft !== undefined}
<div
class="sync sync-{draft.syncStatus}"
data-testid="order-sync"
data-sync-status={draft.syncStatus}
>
{submitInFlight
? i18n.t("game.sidebar.order.submit_in_flight")
: i18n.t("game.sidebar.order.submit")}
</button>
{#if submitError !== null}
<p class="error" data-testid="order-submit-error">{submitError}</p>
{/if}
<span class="sync-label">
{#if draft.syncStatus === "syncing"}
{i18n.t("game.sidebar.order.sync.in_flight")}
{:else if draft.syncStatus === "synced"}
{i18n.t("game.sidebar.order.sync.synced")}
{:else if draft.syncStatus === "error"}
{i18n.t("game.sidebar.order.sync.error", {
message: draft.syncError ?? "",
})}
{:else}
{i18n.t("game.sidebar.order.sync.idle")}
{/if}
</span>
{#if draft.syncStatus === "error"}
<button
type="button"
class="sync-retry"
data-testid="order-sync-retry"
onclick={() => draft.forceSync()}
>
{i18n.t("game.sidebar.order.sync.retry")}
</button>
{/if}
</div>
{/if}
</section>
@@ -274,26 +207,35 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
color: #e8eaf6;
border-color: #6d8cff;
}
.submit {
.sync {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.8rem;
color: #aab;
}
.sync-error {
color: #d97a7a;
}
.sync-synced {
color: #8be9a3;
}
.sync-syncing {
color: #6d8cff;
}
.sync-retry {
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 1rem;
background: #1d2440;
color: #e8eaf6;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.submit:not(:disabled):hover {
.sync-retry:hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.submit:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.error {
margin: 0.5rem 0 0;
color: #d97a7a;
font-size: 0.85rem;
}
</style>