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
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>
422 lines
12 KiB
Svelte
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>
|