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
+8
View File
@@ -37,6 +37,13 @@ type Status = "idle" | "loading" | "ready" | "error";
export class GameStateStore {
gameId: string = $state("");
/**
* gameName mirrors the lobby's `game_name` for the running game.
* Lifted from the lobby record on `setGame`; empty during boot
* and set once the lobby query resolves. Used by the header to
* compose the `<race> @ <game>, turn N` display.
*/
gameName: string = $state("");
status: Status = $state("idle");
report: GameReport | null = $state(null);
wrapMode: WrapMode = $state("torus");
@@ -95,6 +102,7 @@ export class GameStateStore {
this.error = `game ${gameId} is not in your list`;
return;
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn);
} catch (err) {
+47 -10
View File
@@ -1,16 +1,25 @@
<!--
Top header for the in-game shell. Composes the four artifacts called
out by `ui/PLAN.md` Phase 10: race name (static placeholder), turn
counter (static placeholder), view dropdown / hamburger, account
menu. The sidebar-toggle slot to its left appears only on tablet
viewports (7681024 px) and is wired by `+layout.svelte`.
Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name, turn N), view dropdown / hamburger, and the
account menu. The sidebar-toggle slot to its left appears only on
tablet viewports (7681024 px) and is wired by `+layout.svelte`.
The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece
is missing (boot, network error) we fall back to the
`game.shell.unknown` placeholder so the header chrome keeps its
shape.
The connection-state indicator from the IA section is intentionally
absent until Phase 24 wires push-event state.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import TurnCounter from "./turn-counter.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte";
@@ -20,14 +29,42 @@ absent until Phase 24 wires push-event state.
onToggleSidebar: () => void;
};
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const raceName = $derived.by(() => {
const name = gameState?.report?.race;
return name === undefined || name === ""
? i18n.t("game.shell.unknown")
: name;
});
const gameName = $derived.by(() => {
const name = gameState?.gameName ?? "";
return name === "" ? i18n.t("game.shell.unknown") : name;
});
const turn = $derived.by(() => {
const report = gameState?.report;
return report === null || report === undefined
? i18n.t("game.shell.unknown")
: String(report.turn);
});
const headline = $derived(
i18n.t("game.shell.headline", {
race: raceName,
game: gameName,
turn,
}),
);
</script>
<header class="game-shell-header" data-testid="game-shell-header">
<div class="left">
<span class="race" data-testid="race-name">
{i18n.t("game.shell.race_placeholder")}
<span class="headline" data-testid="game-shell-headline">
{headline}
</span>
<TurnCounter />
</div>
<div class="right">
<button
@@ -69,7 +106,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem;
min-width: 0;
}
.race {
.headline {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -1,37 +0,0 @@
<!--
Phase 11 turn counter: reads the live turn number from the per-game
`GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. Renders the static `?` placeholder
from `game.shell.turn_unknown` when the store has not yet produced a
report (boot, network error, no membership) so the header chrome
keeps its width across loading transitions.
Phase 26 will turn this into a clickable trigger that opens the
turn navigator; Phase 24 wires push-event-driven turn-ready toasts
that may flash this counter when a new turn is ready.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { GAME_STATE_CONTEXT_KEY, type GameStateStore } from "$lib/game-state.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const display = $derived.by(() => {
const report = store?.report ?? null;
if (report === null) return i18n.t("game.shell.turn_unknown");
return String(report.turn);
});
</script>
<span class="turn" data-testid="turn-counter" data-turn={display}>
{i18n.t("game.shell.turn_label")}&nbsp;{display}
</span>
<style>
.turn {
font-size: 0.95rem;
color: #ddd;
white-space: nowrap;
}
</style>
+7 -6
View File
@@ -83,9 +83,8 @@ const en = {
"lobby.error.internal_error": "internal server error",
"lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "race ?",
"game.shell.turn_label": "turn",
"game.shell.turn_unknown": "?",
"game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, turn {turn}",
"game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…",
"game.shell.connection.offline": "offline",
@@ -120,8 +119,11 @@ const en = {
"game.sidebar.empty.inspector": "select an object on the map",
"game.sidebar.empty.order": "order is empty",
"game.sidebar.order.command_delete": "delete",
"game.sidebar.order.submit": "submit",
"game.sidebar.order.submit_in_flight": "submitting…",
"game.sidebar.order.sync.idle": "no changes yet",
"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.retry": "retry",
"game.sidebar.order.status.draft": "draft",
"game.sidebar.order.status.valid": "valid",
"game.sidebar.order.status.invalid": "invalid",
@@ -130,7 +132,6 @@ const en = {
"game.sidebar.order.status.rejected": "rejected",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
"game.sidebar.order.error.batch_failed": "submit failed: {message}",
"game.bottom_tabs.map": "map",
"game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order",
+7 -6
View File
@@ -84,9 +84,8 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.internal_error": "внутренняя ошибка сервера",
"lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "раса ?",
"game.shell.turn_label": "ход",
"game.shell.turn_unknown": "?",
"game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, ход {turn}",
"game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…",
"game.shell.connection.offline": "офлайн",
@@ -121,8 +120,11 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.empty.inspector": "выберите объект на карте",
"game.sidebar.empty.order": "приказ пуст",
"game.sidebar.order.command_delete": "удалить",
"game.sidebar.order.submit": "отправить",
"game.sidebar.order.submit_in_flight": "отправка…",
"game.sidebar.order.sync.idle": "нет изменений",
"game.sidebar.order.sync.in_flight": "синхронизация…",
"game.sidebar.order.sync.synced": "сохранено на сервере",
"game.sidebar.order.sync.error": "ошибка синхронизации: {message}",
"game.sidebar.order.sync.retry": "повторить",
"game.sidebar.order.status.draft": "черновик",
"game.sidebar.order.status.valid": "готова",
"game.sidebar.order.status.invalid": "ошибка",
@@ -131,7 +133,6 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.status.rejected": "отклонена",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
"game.sidebar.order.error.batch_failed": "ошибка отправки: {message}",
"game.bottom_tabs.map": "карта",
"game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ",
+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>