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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -164,12 +164,17 @@ fresh.
|
||||
orderDraft.init({ cache, gameId }),
|
||||
]);
|
||||
galaxyClient.set(client);
|
||||
if (orderDraft.needsServerHydration) {
|
||||
await orderDraft.hydrateFromServer({
|
||||
client,
|
||||
turn: gameState.currentTurn,
|
||||
});
|
||||
}
|
||||
orderDraft.bindClient(client);
|
||||
// The server is always polled at game boot — its
|
||||
// stored order may be fresher than the local cache
|
||||
// (e.g. user is on a new device), and an offline
|
||||
// edit must catch up at re-sync time. The hydration
|
||||
// is non-fatal: a network error keeps the local
|
||||
// cache and surfaces through `draft.syncStatus`.
|
||||
await orderDraft.hydrateFromServer({
|
||||
client,
|
||||
turn: gameState.currentTurn,
|
||||
});
|
||||
} catch (err) {
|
||||
gameState.failBootstrap(describeBootstrapError(err));
|
||||
}
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
// Draft state is persisted into the platform `Cache` under the
|
||||
// `order-drafts` namespace with a per-game key, so a reload, a
|
||||
// browser restart, or a navigation through the lobby and back into
|
||||
// the same game restores the previously composed list. Phase 14
|
||||
// will add the submit pipeline that drains the draft to the server;
|
||||
// Phase 26 will hide the order tab in history mode through a flag
|
||||
// passed by the layout (the store itself remains alive across that
|
||||
// transition so the draft survives history-mode round-trips).
|
||||
// the same game restores the previously composed list.
|
||||
//
|
||||
// Phase 14 wires the auto-sync pipeline: every successful mutation
|
||||
// (`add` / `remove` / `move`) coalesces a `submitOrder` call so the
|
||||
// server always mirrors the local draft. The Submit button is gone —
|
||||
// the player's intent is the source of truth and the engine is kept
|
||||
// in lock-step. Phase 26 will hide the order tab in history mode
|
||||
// through a flag passed by the layout (the store itself remains
|
||||
// alive across that transition so the draft survives history-mode
|
||||
// round-trips).
|
||||
//
|
||||
// The store deliberately carries no Svelte component imports so it
|
||||
// can be tested directly with a synthetic `Cache` without rendering
|
||||
@@ -20,6 +25,7 @@ import type { Cache } from "../platform/store/index";
|
||||
import type { GalaxyClient } from "../api/galaxy-client";
|
||||
import { fetchOrder } from "./order-load";
|
||||
import type { CommandStatus, OrderCommand } from "./order-types";
|
||||
import { submitOrder } from "./submit";
|
||||
import { validateEntityName } from "$lib/util/entity-name";
|
||||
|
||||
const NAMESPACE = "order-drafts";
|
||||
@@ -35,6 +41,8 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
|
||||
|
||||
type Status = "idle" | "ready" | "error";
|
||||
|
||||
export type SyncStatus = "idle" | "syncing" | "synced" | "error";
|
||||
|
||||
export class OrderDraftStore {
|
||||
commands: OrderCommand[] = $state([]);
|
||||
statuses: Record<string, CommandStatus> = $state({});
|
||||
@@ -43,18 +51,26 @@ export class OrderDraftStore {
|
||||
error: string | null = $state(null);
|
||||
|
||||
/**
|
||||
* needsServerHydration is `true` when the cache row for this game
|
||||
* was absent at `init` time. The layout reads it after both
|
||||
* `gameState.init` and `orderDraft.init` resolve and, if `true`,
|
||||
* calls `hydrateFromServer` once the current turn is known.
|
||||
* An explicitly empty cache row sets it to `false` (the user has
|
||||
* an empty draft, not a missing one).
|
||||
* syncStatus reflects the auto-sync pipeline state for the order
|
||||
* tab status bar:
|
||||
* - `idle` — no sync attempted yet (e.g. fresh draft after
|
||||
* hydration or before the first mutation).
|
||||
* - `syncing` — a `submitOrder` call is in flight.
|
||||
* - `synced` — the last sync succeeded; statuses match the
|
||||
* server's view.
|
||||
* - `error` — the last sync failed (network or non-`ok`); the
|
||||
* next mutation triggers a retry, or the user can
|
||||
* force a re-sync via `forceSync`.
|
||||
*/
|
||||
needsServerHydration = $state(false);
|
||||
syncStatus: SyncStatus = $state("idle");
|
||||
syncError: string | null = $state(null);
|
||||
|
||||
private cache: Cache | null = null;
|
||||
private gameId = "";
|
||||
private destroyed = false;
|
||||
private client: GalaxyClient | null = null;
|
||||
private syncing: Promise<void> | null = null;
|
||||
private pending = false;
|
||||
|
||||
/**
|
||||
* init loads the persisted draft for `opts.gameId` from `opts.cache`
|
||||
@@ -63,11 +79,11 @@ export class OrderDraftStore {
|
||||
* constructs a fresh store per game, so there is no need to support
|
||||
* mid-life game switching here.
|
||||
*
|
||||
* When the cache row is absent, `needsServerHydration` is set to
|
||||
* `true`; the layout fans out a `hydrateFromServer` call once the
|
||||
* current turn is known. An explicitly empty cache row is treated
|
||||
* as "user has an empty draft" and skipped — local intent always
|
||||
* wins over server snapshot.
|
||||
* The cache load is the fast path so the order tab paints
|
||||
* immediately on reopening the game; `hydrateFromServer` (called
|
||||
* by the layout once the current turn is known) is the
|
||||
* authoritative read that always overwrites the local cache when
|
||||
* the server has a stored order.
|
||||
*/
|
||||
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
|
||||
this.cache = opts.cache;
|
||||
@@ -78,13 +94,7 @@ export class OrderDraftStore {
|
||||
draftKey(opts.gameId),
|
||||
);
|
||||
if (this.destroyed) return;
|
||||
if (stored === undefined) {
|
||||
this.commands = [];
|
||||
this.needsServerHydration = true;
|
||||
} else {
|
||||
this.commands = Array.isArray(stored) ? [...stored] : [];
|
||||
this.needsServerHydration = false;
|
||||
}
|
||||
this.commands = Array.isArray(stored) ? [...stored] : [];
|
||||
this.recomputeStatuses();
|
||||
this.status = "ready";
|
||||
} catch (err) {
|
||||
@@ -95,37 +105,64 @@ export class OrderDraftStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* hydrateFromServer fetches the player's stored order from the
|
||||
* gateway when the cache row was absent at boot. The result is
|
||||
* merged into `commands` and persisted so subsequent reloads
|
||||
* prefer the cached version. Failures are non-fatal — the draft
|
||||
* stays empty and the user can keep composing.
|
||||
* bindClient stores the per-game `GalaxyClient` so subsequent
|
||||
* mutations can drive the auto-sync pipeline. The layout calls
|
||||
* this after the boot `Promise.all` resolves and before
|
||||
* `hydrateFromServer`, so any mutation that lands afterwards goes
|
||||
* through the network.
|
||||
*/
|
||||
bindClient(client: GalaxyClient): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* hydrateFromServer issues `user.games.order.get` for the current
|
||||
* turn and overwrites the local cache with the server's stored
|
||||
* order. The server is the source of truth: a player who logged
|
||||
* in from a fresh device must see their existing orders, and a
|
||||
* cache that's out-of-sync (e.g. a stale browser tab) is
|
||||
* superseded by the gateway's view. A `found = false` answer
|
||||
* empties the local draft. Network failures keep the local cache
|
||||
* intact and surface as `syncStatus = "error"`.
|
||||
*/
|
||||
async hydrateFromServer(opts: {
|
||||
client: GalaxyClient;
|
||||
turn: number;
|
||||
}): Promise<void> {
|
||||
if (this.status !== "ready" || !this.needsServerHydration) return;
|
||||
this.needsServerHydration = false;
|
||||
if (this.status !== "ready") return;
|
||||
this.client = opts.client;
|
||||
this.syncStatus = "syncing";
|
||||
this.syncError = null;
|
||||
try {
|
||||
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
|
||||
if (this.destroyed) return;
|
||||
this.commands = fetched.commands;
|
||||
this.updatedAt = fetched.updatedAt;
|
||||
this.recomputeStatuses();
|
||||
// Server-fetched commands echo cmdApplied=true for entries
|
||||
// that survived previous turns; keep them as `applied` so
|
||||
// the overlay continues to project them on the inspector.
|
||||
const next = { ...this.statuses };
|
||||
for (const cmd of this.commands) {
|
||||
if (next[cmd.id] === "valid") {
|
||||
next[cmd.id] = "applied";
|
||||
}
|
||||
}
|
||||
this.statuses = next;
|
||||
await this.persist();
|
||||
this.syncStatus = "synced";
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
console.warn(
|
||||
"order-draft: server hydration failed; staying on empty draft",
|
||||
err,
|
||||
);
|
||||
this.syncStatus = "error";
|
||||
this.syncError = err instanceof Error ? err.message : "fetch failed";
|
||||
console.warn("order-draft: server hydration failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* add appends a command to the end of the draft, runs local
|
||||
* validation for the new entry, and persists the updated list.
|
||||
* validation for the new entry, persists the updated list, and
|
||||
* triggers an auto-sync to keep the server in lock-step.
|
||||
* Mutations made before `init` resolves are ignored — the layout
|
||||
* always awaits `init` before exposing the store.
|
||||
*/
|
||||
@@ -134,11 +171,15 @@ export class OrderDraftStore {
|
||||
this.commands = [...this.commands, command];
|
||||
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
|
||||
await this.persist();
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* remove drops the command with the given id from the draft and
|
||||
* persists the result. A miss is a no-op.
|
||||
* remove drops the command with the given id from the draft,
|
||||
* persists the result, and triggers an auto-sync. A miss is a
|
||||
* no-op. Even removing the last command sends an explicit empty
|
||||
* order to the server so its stored state matches the local one
|
||||
* (the engine accepts an empty `cmd[]` per the order handler).
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
if (this.status !== "ready") return;
|
||||
@@ -149,13 +190,16 @@ export class OrderDraftStore {
|
||||
delete nextStatuses[id];
|
||||
this.statuses = nextStatuses;
|
||||
await this.persist();
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* move relocates the command at `fromIndex` to `toIndex`, shifting
|
||||
* the intermediate commands. Out-of-range indices and identical
|
||||
* positions are no-ops; both indices are clamped against the
|
||||
* current `commands` length.
|
||||
* current `commands` length. Triggers an auto-sync — the server
|
||||
* stores commands in submission order and the engine relies on
|
||||
* that order at turn cutoff.
|
||||
*/
|
||||
async move(fromIndex: number, toIndex: number): Promise<void> {
|
||||
if (this.status !== "ready") return;
|
||||
@@ -169,49 +213,137 @@ export class OrderDraftStore {
|
||||
next.splice(toIndex, 0, picked);
|
||||
this.commands = next;
|
||||
await this.persist();
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* markSubmitting flips the status of every entry in `ids` to
|
||||
* `submitting` so the order tab can disable per-row controls and
|
||||
* show a spinner. The state machine runs `valid → submitting →
|
||||
* applied | rejected` (see ui/docs/order-composer.md).
|
||||
* forceSync re-runs the auto-sync without requiring a mutation.
|
||||
* Used by the order tab's retry-on-error affordance.
|
||||
*/
|
||||
markSubmitting(ids: string[]): void {
|
||||
forceSync(): void {
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.destroyed = true;
|
||||
this.cache = null;
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
if (this.client === null) return;
|
||||
if (this.syncing !== null) {
|
||||
this.pending = true;
|
||||
return;
|
||||
}
|
||||
this.syncing = this.runSync().finally(() => {
|
||||
this.syncing = null;
|
||||
});
|
||||
}
|
||||
|
||||
private async runSync(): Promise<void> {
|
||||
while (true) {
|
||||
this.pending = false;
|
||||
const client = this.client;
|
||||
if (client === null || this.destroyed) return;
|
||||
|
||||
// Capture the snapshot up-front: the in-flight request
|
||||
// reflects the draft as it was when the mutation landed,
|
||||
// even if the user adds another command before the
|
||||
// gateway responds.
|
||||
const snapshot: OrderCommand[] = $state.snapshot(
|
||||
this.commands,
|
||||
) as OrderCommand[];
|
||||
// Auto-sync sends every command the player still has in
|
||||
// the draft except the locally-invalid ones (we can't
|
||||
// expect the server to accept a name that fails our own
|
||||
// validator) and the Phase 12 placeholder. `applied` and
|
||||
// `rejected` entries are re-sent so the server's stored
|
||||
// view always mirrors the local one — re-applying an
|
||||
// already-applied command is idempotent at the engine
|
||||
// level (the rename ends at the same name).
|
||||
const submittable = snapshot.filter((cmd) => {
|
||||
const status = this.statuses[cmd.id];
|
||||
return status !== "invalid" && status !== "draft";
|
||||
});
|
||||
const submittingIds = submittable.map((cmd) => cmd.id);
|
||||
|
||||
this.markSubmittingInternal(submittingIds);
|
||||
this.syncStatus = "syncing";
|
||||
this.syncError = null;
|
||||
|
||||
try {
|
||||
const result = await submitOrder(
|
||||
client,
|
||||
this.gameId,
|
||||
submittable,
|
||||
{ updatedAt: this.updatedAt },
|
||||
);
|
||||
if (this.destroyed) return;
|
||||
if (result.ok) {
|
||||
this.applyResultsInternal(result.results, result.updatedAt);
|
||||
// Even with `result.ok === true` an individual
|
||||
// command may have been rejected by the engine
|
||||
// (e.g. validation passed transcoders but failed
|
||||
// the in-game rule). Surface that as an error in
|
||||
// the sync bar so the player notices and can fix
|
||||
// or remove the offending command.
|
||||
const anyRejected = Array.from(result.results.values()).some(
|
||||
(s) => s === "rejected",
|
||||
);
|
||||
this.syncStatus = anyRejected ? "error" : "synced";
|
||||
this.syncError = anyRejected
|
||||
? "engine rejected one or more commands"
|
||||
: null;
|
||||
} else {
|
||||
this.markRejectedInternal(submittingIds);
|
||||
this.syncStatus = "error";
|
||||
this.syncError = result.message;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
this.revertSubmittingToValidInternal();
|
||||
this.syncStatus = "error";
|
||||
this.syncError = err instanceof Error ? err.message : "sync failed";
|
||||
}
|
||||
|
||||
if (!this.pending) return;
|
||||
}
|
||||
}
|
||||
|
||||
private markSubmittingInternal(ids: string[]): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const id of ids) {
|
||||
next[id] = "submitting";
|
||||
// `applied` rows stay applied while the wire request is in
|
||||
// flight — re-sending an already-applied command is a
|
||||
// no-op idempotent operation, and flipping the badge back
|
||||
// to `submitting` would flicker the inspector overlay.
|
||||
if (next[id] === "valid" || next[id] === "rejected") {
|
||||
next[id] = "submitting";
|
||||
}
|
||||
}
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* applyResults merges the verdict map returned by `submitOrder`
|
||||
* into the per-command status map. Entries not present in the
|
||||
* map keep their current status — useful when only a subset of
|
||||
* commands round-tripped to the server. The engine-assigned
|
||||
* `updatedAt` is also stashed for the next submit's stale-order
|
||||
* detection (kept as plumbing only in Phase 14).
|
||||
*/
|
||||
applyResults(opts: {
|
||||
results: Map<string, CommandStatus>;
|
||||
updatedAt: number;
|
||||
}): void {
|
||||
private applyResultsInternal(
|
||||
results: Map<string, CommandStatus>,
|
||||
updatedAt: number,
|
||||
): void {
|
||||
const liveIds = new Set(this.commands.map((cmd) => cmd.id));
|
||||
const next = { ...this.statuses };
|
||||
for (const [id, status] of opts.results.entries()) {
|
||||
for (const [id, status] of results.entries()) {
|
||||
// Drop verdicts for commands the user removed while the
|
||||
// request was in flight — they are no longer in the
|
||||
// draft, so re-introducing a stale `applied` row would
|
||||
// confuse the order tab and the overlay.
|
||||
if (!liveIds.has(id)) continue;
|
||||
next[id] = status;
|
||||
}
|
||||
this.statuses = next;
|
||||
this.updatedAt = opts.updatedAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* markRejected switches every supplied id to `rejected`. Used by
|
||||
* the order tab when `submitOrder` returns `ok: false` — the
|
||||
* gateway didn't process any command, so the entire batch is
|
||||
* treated as rejected.
|
||||
*/
|
||||
markRejected(ids: string[]): void {
|
||||
private markRejectedInternal(ids: string[]): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const id of ids) {
|
||||
next[id] = "rejected";
|
||||
@@ -219,13 +351,7 @@ export class OrderDraftStore {
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* revertSubmittingToValid resets every entry currently in
|
||||
* `submitting` back to its pre-submit status (typically `valid`).
|
||||
* Called when the network layer throws an exception so the
|
||||
* operator can retry without the rows looking stuck mid-flight.
|
||||
*/
|
||||
revertSubmittingToValid(): void {
|
||||
private revertSubmittingToValidInternal(): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const cmd of this.commands) {
|
||||
if (next[cmd.id] === "submitting") {
|
||||
@@ -235,11 +361,6 @@ export class OrderDraftStore {
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.destroyed = true;
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
private recomputeStatuses(): void {
|
||||
const next: Record<string, CommandStatus> = {};
|
||||
for (const cmd of this.commands) {
|
||||
|
||||
Reference in New Issue
Block a user