7bea22b0b5
Lights up the player-defined sciences feature: a table view with sort and filter, a designer with four percent inputs and a strict sum-equals-100 gate, and a Research-sub-row integration so the planet production picker lists the user's sciences alongside the four tech buttons. Phase 21 decisions are baked back into ui/PLAN.md (no UpdateScience on the wire — write-once via createScience + removeScience; percentages instead of fractions; sciences live under the existing Research segment). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
326 lines
8.5 KiB
Svelte
326 lines
8.5 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.
|
|
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 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",
|
|
};
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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";
|
|
}
|
|
</script>
|
|
|
|
<section class="tool" data-testid="sidebar-tool-order">
|
|
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
|
|
{#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)}
|
|
<li
|
|
class="command"
|
|
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>
|
|
<span
|
|
class="status status-{status}"
|
|
data-testid="order-command-status-{index}"
|
|
>
|
|
{i18n.t(statusKeyMap[status])}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="delete"
|
|
data-testid="order-command-delete-{index}"
|
|
onclick={() => draft?.remove(cmd.id)}
|
|
>
|
|
{i18n.t("game.sidebar.order.command_delete")}
|
|
</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}
|
|
{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: #888;
|
|
}
|
|
.commands {
|
|
list-style: none;
|
|
margin: 0 0 0.75rem;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
.command {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto auto;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.4rem 0.5rem;
|
|
background: #14182a;
|
|
border: 1px solid #20253a;
|
|
border-radius: 4px;
|
|
}
|
|
.index {
|
|
color: #aab;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.label {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.status {
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
padding: 0.1rem 0.4rem;
|
|
border-radius: 999px;
|
|
border: 1px solid #2a3150;
|
|
color: #aab;
|
|
}
|
|
.status-applied {
|
|
color: #8be9a3;
|
|
border-color: #2f6d3f;
|
|
}
|
|
.status-rejected {
|
|
color: #d97a7a;
|
|
border-color: #6d2f2f;
|
|
}
|
|
.status-invalid {
|
|
color: #d6b86c;
|
|
border-color: #6d562f;
|
|
}
|
|
.status-submitting {
|
|
color: #6d8cff;
|
|
border-color: #2f3f6d;
|
|
}
|
|
.delete {
|
|
font: inherit;
|
|
font-size: 0.85rem;
|
|
padding: 0.2rem 0.55rem;
|
|
background: transparent;
|
|
color: #aab;
|
|
border: 1px solid #2a3150;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
}
|
|
.delete:hover {
|
|
color: #e8eaf6;
|
|
border-color: #6d8cff;
|
|
}
|
|
.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.8rem;
|
|
padding: 0.15rem 0.5rem;
|
|
background: transparent;
|
|
color: #aab;
|
|
border: 1px solid #2a3150;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
}
|
|
.sync-retry:hover {
|
|
color: #e8eaf6;
|
|
border-color: #6d8cff;
|
|
}
|
|
</style>
|