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:
@@ -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) {
|
||||
|
||||
@@ -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 (768–1024 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 (768–1024 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")} {display}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.turn {
|
||||
font-size: 0.95rem;
|
||||
color: #ddd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "приказ",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user