Files
galaxy-game/ui/frontend/src/lib/sidebar/order-tab.svelte
T
Ilia Denisov 723885e74e
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m3s
Tests · Go / test (pull_request) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · UI / test (pull_request) Failing after 4m28s
fix(order): surface rejection reason, keep sync green, hydrate verdicts
Three issues surfaced once the per-command rejection from the previous
commit actually reached the UI:

1. Sync banner falsely red. `OrderDraftStore.runSync` flipped
   `syncStatus = "error"` whenever any command was rejected and
   advertised a Retry button. A per-command rejection is a
   player-correctable state — the round trip succeeded, the engine
   just refused that command — so the retry can't help. Keep
   `syncStatus = "synced"` on `success`; the red row highlight is
   the visible cue.

2. Rejection reason missing. Add `cmd_error_message: string` to
   `CommandItem` in `pkg/schema/fbs/order.fbs` (appended last to
   preserve existing slot offsets) and regenerate the Go + TS stubs
   for that one type. Plumb the message through `CommandMeta`,
   `Controller.applyCommand`'s `m.Result(code, message)` call, the
   Go transcoder, the UI decoders in `submit.ts` /  `order-load.ts`,
   and the `OrderDraftStore.errorMessages` map. `order-tab.svelte`
   renders it as an italic danger-coloured line under rejected
   commands, with new CSS for `.error-reason`.

3. Verdict lost on navigation. `order-load.ts.decodeCommand` never
   read `cmdApplied`/`cmdErrorCode`, so `hydrateFromServer` fell
   back to a blanket "applied" status — a previously-rejected
   command came back green after a lobby → game round trip. Extend
   the fetch decoder to populate `statuses`/`errorCodes`/
   `errorMessages` maps and have `hydrateFromServer` use them.
   Engine-side persistence already records the verdict on disk —
   verified against the live `0000/order/<id>.json`.

`flatbuffers@25` elides default-int8/int64 fields on write; the Go
transcoder force-slots `cmd_applied=false` / `cmd_error_code=0`
already, the new test fixtures flip `builder.forceDefaults(true)` to
mirror that behaviour so the round trip survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:42:27 +02:00

422 lines
12 KiB
Svelte

<!--
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 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.
Per-command status (`valid`, `submitting`, `applied`, `rejected`,
`conflict`, `invalid`) is conveyed by the card's background tint
(`status-{status}` class → `--color-{success,danger,warning}-subtle`),
while the textual status name stays in the DOM as an `sr-only`
node so screen readers and the existing tests still observe it.
A small status bar at the bottom surfaces the latest sync result.
The earlier explicit Submit button is gone — there is no separate
"send" step anymore.
Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
(Playwright) and via direct store / mocked-client construction
(Vitest).
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { productionDisplayFromCommand } from "../../api/game-state";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import type { CommandStatus, OrderCommand } from "../../sync/order-types";
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const statusKeyMap: Record<CommandStatus, TranslationKey> = {
draft: "game.sidebar.order.status.draft",
valid: "game.sidebar.order.status.valid",
invalid: "game.sidebar.order.status.invalid",
submitting: "game.sidebar.order.status.submitting",
applied: "game.sidebar.order.status.applied",
rejected: "game.sidebar.order.status.rejected",
conflict: "game.sidebar.order.status.conflict",
};
function describe(cmd: OrderCommand): string {
switch (cmd.kind) {
case "placeholder":
return i18n.t("game.sidebar.order.label.placeholder", {
label: cmd.label,
});
case "planetRename":
return i18n.t("game.sidebar.order.label.planet_rename", {
planet: String(cmd.planetNumber),
name: cmd.name,
});
case "setProductionType":
return i18n.t("game.sidebar.order.label.planet_production", {
planet: String(cmd.planetNumber),
target: productionDisplayFromCommand(
cmd.productionType,
cmd.subject,
),
});
case "setCargoRoute":
return i18n.t("game.sidebar.order.label.cargo_route_set", {
loadType: cmd.loadType,
source: String(cmd.sourcePlanetNumber),
destination: String(cmd.destinationPlanetNumber),
});
case "removeCargoRoute":
return i18n.t("game.sidebar.order.label.cargo_route_remove", {
loadType: cmd.loadType,
source: String(cmd.sourcePlanetNumber),
});
case "createShipClass":
return i18n.t("game.sidebar.order.label.ship_class_create", {
name: cmd.name,
});
case "removeShipClass":
return i18n.t("game.sidebar.order.label.ship_class_remove", {
name: cmd.name,
});
case "createScience":
return i18n.t("game.sidebar.order.label.science_create", {
name: cmd.name,
});
case "removeScience":
return i18n.t("game.sidebar.order.label.science_remove", {
name: cmd.name,
});
case "breakShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_break", {
group: shortGroupId(cmd.groupId),
quantity: String(cmd.quantity),
});
case "sendShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_send", {
group: shortGroupId(cmd.groupId),
destination: String(cmd.destinationPlanetNumber),
});
case "loadShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_load", {
group: shortGroupId(cmd.groupId),
cargo: cmd.cargo,
quantity: String(cmd.quantity),
});
case "unloadShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_unload", {
group: shortGroupId(cmd.groupId),
quantity: String(cmd.quantity),
});
case "upgradeShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_upgrade", {
group: shortGroupId(cmd.groupId),
tech: cmd.tech,
level: String(cmd.level),
});
case "dismantleShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_dismantle", {
group: shortGroupId(cmd.groupId),
});
case "transferShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_transfer", {
group: shortGroupId(cmd.groupId),
acceptor: cmd.acceptor,
});
case "joinFleetShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_join_fleet", {
group: shortGroupId(cmd.groupId),
fleet: cmd.name,
});
case "setDiplomaticStance":
return i18n.t("game.sidebar.order.label.race_relation", {
relation: cmd.relation,
acceptor: cmd.acceptor,
});
case "setVoteRecipient":
return i18n.t("game.sidebar.order.label.race_vote", {
acceptor: cmd.acceptor,
});
}
}
// Short identifier for the order-tab so the human-readable label
// stays glanceable; the full UUID is still in the underlying
// command and visible in the inspector overlay.
function shortGroupId(uuid: string): string {
return uuid.length > 8 ? uuid.slice(0, 8) : uuid;
}
function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft";
}
function errorMessageOf(cmd: OrderCommand): string | null {
return draft?.errorMessages[cmd.id] ?? null;
}
</script>
<section class="tool" data-testid="sidebar-tool-order">
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
{#if draft !== undefined && draft.pausedBanner !== null}
<div
class="banner banner-paused"
data-testid="order-paused-banner"
data-paused-reason={draft.pausedBanner.reason}
role="status"
>
{i18n.t("game.sidebar.order.paused.banner")}
</div>
{/if}
{#if draft !== undefined && draft.conflictBanner !== null}
<div
class="banner banner-conflict"
data-testid="order-conflict-banner"
data-conflict-turn={draft.conflictBanner.turn ?? ""}
role="status"
>
{#if draft.conflictBanner.turn !== null}
{i18n.t("game.sidebar.order.conflict.banner", {
turn: String(draft.conflictBanner.turn),
})}
{:else}
{i18n.t("game.sidebar.order.conflict.banner_no_turn")}
{/if}
</div>
{/if}
{#if draft === undefined || draft.commands.length === 0}
<p class="empty" data-testid="order-empty">
{i18n.t("game.sidebar.empty.order")}
</p>
{:else}
<ol class="commands" data-testid="order-list">
{#each draft.commands as cmd, index (cmd.id)}
{@const status = statusOf(cmd)}
{@const errorReason = errorMessageOf(cmd)}
<li
class="command status-{status}"
data-testid="order-command-{index}"
data-command-status={status}
>
<span class="index" aria-hidden="true">{index + 1}.</span>
<span class="label" data-testid="order-command-label-{index}">
{describe(cmd)}
</span>
{#if status === "rejected" && errorReason !== null}
<span
class="error-reason"
data-testid="order-command-error-{index}"
>
{errorReason}
</span>
{/if}
<span
class="sr-only"
data-testid="order-command-status-{index}"
>
{i18n.t(statusKeyMap[status])}
</span>
<button
type="button"
class="delete"
data-testid="order-command-delete-{index}"
aria-label={i18n.t("game.sidebar.order.command_delete")}
title={i18n.t("game.sidebar.order.command_delete")}
onclick={() => draft?.remove(cmd.id)}
>
<span aria-hidden="true"></span>
</button>
</li>
{/each}
</ol>
{/if}
{#if draft !== undefined}
<div
class="sync sync-{draft.syncStatus}"
data-testid="order-sync"
data-sync-status={draft.syncStatus}
>
<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 if draft.syncStatus === "offline"}
{i18n.t("game.sidebar.order.sync.offline")}
{:else if draft.syncStatus === "conflict"}
{i18n.t("game.sidebar.order.sync.conflict")}
{:else if draft.syncStatus === "paused"}
{i18n.t("game.sidebar.order.sync.paused")}
{: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>
<style>
.tool {
padding: 1rem;
font-family: system-ui, sans-serif;
}
.tool h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.empty {
margin: 0;
color: var(--color-text-muted);
}
.commands {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.command {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
align-items: baseline;
gap: 0.4rem;
padding: 0.3rem 1.1rem 0.3rem 0.5rem;
font-size: 0.8rem;
line-height: 1.3;
background: var(--color-surface-overlay);
border: 1px solid var(--color-border-subtle);
border-radius: 4px;
}
.command.status-applied {
background: var(--color-success-subtle);
}
.command.status-invalid,
.command.status-rejected,
.command.status-conflict {
background: var(--color-danger-subtle);
}
.command.status-draft,
.command.status-valid,
.command.status-submitting {
background: var(--color-warning-subtle);
}
.index {
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.label {
min-width: 0;
overflow-wrap: anywhere;
}
.error-reason {
grid-column: 2;
min-width: 0;
overflow-wrap: anywhere;
margin-top: 0.1rem;
color: var(--color-danger);
font-size: 0.75rem;
font-style: italic;
line-height: 1.25;
}
.banner {
margin: 0 0 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.3;
}
.banner-conflict {
color: var(--color-warning);
background: var(--color-warning-subtle);
border: 1px solid var(--color-warning);
}
.banner-paused {
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.delete {
position: absolute;
top: 0;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.95rem;
height: 0.95rem;
padding: 0;
font: inherit;
font-size: 0.65rem;
line-height: 1;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border-subtle);
border-radius: 0 3px 0 3px;
cursor: pointer;
}
.delete:hover,
.delete:focus-visible {
color: var(--color-text);
border-color: var(--color-accent);
}
.sync {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.sync-error {
color: var(--color-danger);
}
.sync-synced {
color: var(--color-success);
}
.sync-syncing {
color: var(--color-accent);
}
.sync-offline {
color: var(--color-warning);
}
.sync-conflict {
color: var(--color-warning);
}
.sync-paused {
color: var(--color-text-muted);
}
.sync-retry {
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.sync-retry:hover {
color: var(--color-text);
border-color: var(--color-accent);
}
</style>