ui/phase-20: ship-group inspector actions

Eight ship-group operations land on the inspector behind a single
inline-form panel: split, send, load, unload, modernize, dismantle,
transfer, join fleet. Each action either appends a typed command to
the local order draft or surfaces a tooltip explaining the
disabled state. Partial-ship operations emit an implicit
breakShipGroup command before the targeted action so the engine
sees a clean (Break, Action) pair on the wire.

`pkg/calc.BlockUpgradeCost` migrates from
`game/internal/controller/ship_group_upgrade.go` so the calc
bridge can wrap a pure pkg/calc formula; the controller now
imports it. The bridge surfaces the function as
`core.blockUpgradeCost`, which the inspector calls once per ship
block to render the modernize cost preview.

`GameReport.otherRaces` is decoded from the report's player block
(non-extinct, ≠ self) and feeds the transfer-to-race picker. The
planet inspector's stationed-ship rows become clickable for own
groups so the actions panel is reachable from the standard click
flow (the renderer continues to hide on-planet groups).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 16:27:55 +02:00
parent f7109af55c
commit 3626998a33
36 changed files with 4033 additions and 89 deletions
+56
View File
@@ -194,6 +194,14 @@ const en = {
"game.sidebar.order.label.cargo_route_remove": "remove {loadType} route from planet {source}",
"game.sidebar.order.label.ship_class_create": "design ship class {name}",
"game.sidebar.order.label.ship_class_remove": "remove ship class {name}",
"game.sidebar.order.label.ship_group_break": "split group {group} → {quantity} ships into new group",
"game.sidebar.order.label.ship_group_send": "send group {group} → planet {destination}",
"game.sidebar.order.label.ship_group_load": "load {cargo} × {quantity} onto group {group}",
"game.sidebar.order.label.ship_group_unload": "unload × {quantity} from group {group}",
"game.sidebar.order.label.ship_group_upgrade": "modernize group {group} {tech} → {level}",
"game.sidebar.order.label.ship_group_dismantle": "dismantle group {group}",
"game.sidebar.order.label.ship_group_transfer": "transfer group {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}",
"game.table.ship_classes.title": "ship classes",
"game.table.ship_classes.column.name": "name",
"game.table.ship_classes.column.drive": "drive",
@@ -276,6 +284,54 @@ const en = {
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "no data — only the radar blip is known",
"game.inspector.ship_group.action.split": "split",
"game.inspector.ship_group.action.send": "send",
"game.inspector.ship_group.action.load": "load",
"game.inspector.ship_group.action.unload": "unload",
"game.inspector.ship_group.action.modernize": "modernize",
"game.inspector.ship_group.action.dismantle": "dismantle",
"game.inspector.ship_group.action.transfer": "transfer",
"game.inspector.ship_group.action.join_fleet": "join fleet",
"game.inspector.ship_group.action.confirm": "confirm",
"game.inspector.ship_group.action.cancel": "cancel",
"game.inspector.ship_group.action.confirm_destroy": "confirm — colonists die",
"game.inspector.ship_group.action.disabled.not_in_orbit": "ships are busy ({state}); only orbiting groups accept actions",
"game.inspector.ship_group.action.disabled.no_reach": "no planets are within drive range",
"game.inspector.ship_group.action.disabled.no_drive": "this ship class has no drive block",
"game.inspector.ship_group.action.disabled.no_cargo_block": "this ship class has no cargo block",
"game.inspector.ship_group.action.disabled.no_planet": "the orbit planet is not visible",
"game.inspector.ship_group.action.disabled.foreign_planet": "this action is only available on your own or unowned planets",
"game.inspector.ship_group.action.disabled.empty_cargo": "the group is empty",
"game.inspector.ship_group.action.disabled.foreign_unload_col": "colonists cannot be unloaded over a foreign planet",
"game.inspector.ship_group.action.disabled.no_headroom": "the group's tech is already at your race level",
"game.inspector.ship_group.action.disabled.no_planet_stock": "the planet has no available stock of this cargo",
"game.inspector.ship_group.action.disabled.full_load": "the group is fully loaded",
"game.inspector.ship_group.action.disabled.no_other_races": "no other non-extinct races to transfer to",
"game.inspector.ship_group.action.disabled.unknown_class": "the ship class is missing from the report",
"game.inspector.ship_group.action.field.ships": "ships ({max} total)",
"game.inspector.ship_group.action.field.cargo": "cargo type",
"game.inspector.ship_group.action.field.quantity": "quantity",
"game.inspector.ship_group.action.field.level": "tech level",
"game.inspector.ship_group.action.field.tech": "tech",
"game.inspector.ship_group.action.field.acceptor": "acceptor",
"game.inspector.ship_group.action.field.fleet": "fleet name",
"game.inspector.ship_group.action.field.destination": "destination planet",
"game.inspector.ship_group.action.tech.all": "all blocks",
"game.inspector.ship_group.action.tech.drive": "drive",
"game.inspector.ship_group.action.tech.weapons": "weapons",
"game.inspector.ship_group.action.tech.shields": "shields",
"game.inspector.ship_group.action.tech.cargo": "cargo",
"game.inspector.ship_group.action.send.pick_prompt": "click a planet on the map (Esc to cancel)",
"game.inspector.ship_group.action.send.no_destination": "no destination chosen",
"game.inspector.ship_group.action.modernize.cost": "estimated cost: {cost}",
"game.inspector.ship_group.action.modernize.cost_unavailable": "cost preview unavailable",
"game.inspector.ship_group.action.dismantle.warning": "the group is over a foreign planet with colonists aboard — they will die",
"game.inspector.ship_group.action.fleet.create_new": "+ new fleet",
"game.inspector.ship_group.action.invalid.ship_count": "ships must be in the range 1…{max}",
"game.inspector.ship_group.action.invalid.quantity": "quantity must be greater than zero",
"game.inspector.ship_group.action.invalid.level": "level must be in ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "fleet name does not match the entity-name rules",
"game.inspector.planet.ship_groups.title": "stationed ship groups",
"game.inspector.planet.ship_groups.row.count": "{count} ships",
"game.inspector.planet.ship_groups.row.mass": "mass {mass}",
+56
View File
@@ -195,6 +195,14 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.label.cargo_route_remove": "удалить маршрут {loadType} с планеты {source}",
"game.sidebar.order.label.ship_class_create": "сконструировать класс корабля {name}",
"game.sidebar.order.label.ship_class_remove": "удалить класс корабля {name}",
"game.sidebar.order.label.ship_group_break": "разделить группу {group} → новая группа из {quantity} кораблей",
"game.sidebar.order.label.ship_group_send": "отправить группу {group} → планета {destination}",
"game.sidebar.order.label.ship_group_load": "загрузить {cargo} × {quantity} в группу {group}",
"game.sidebar.order.label.ship_group_unload": "выгрузить × {quantity} из группы {group}",
"game.sidebar.order.label.ship_group_upgrade": "модернизация группы {group} {tech} → {level}",
"game.sidebar.order.label.ship_group_dismantle": "разобрать группу {group}",
"game.sidebar.order.label.ship_group_transfer": "передать группу {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
"game.table.ship_classes.title": "классы кораблей",
"game.table.ship_classes.column.name": "название",
"game.table.ship_classes.column.drive": "двигатель",
@@ -277,6 +285,54 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты",
"game.inspector.ship_group.action.split": "разделить",
"game.inspector.ship_group.action.send": "отправить",
"game.inspector.ship_group.action.load": "загрузить",
"game.inspector.ship_group.action.unload": "выгрузить",
"game.inspector.ship_group.action.modernize": "модернизировать",
"game.inspector.ship_group.action.dismantle": "разобрать",
"game.inspector.ship_group.action.transfer": "передать",
"game.inspector.ship_group.action.join_fleet": "во флот",
"game.inspector.ship_group.action.confirm": "подтвердить",
"game.inspector.ship_group.action.cancel": "отмена",
"game.inspector.ship_group.action.confirm_destroy": "подтвердить — колонисты погибнут",
"game.inspector.ship_group.action.disabled.not_in_orbit": "корабли заняты ({state}); действия доступны только на орбите",
"game.inspector.ship_group.action.disabled.no_reach": "в радиусе двигателей нет планет",
"game.inspector.ship_group.action.disabled.no_drive": "у класса корабля нет блока двигателей",
"game.inspector.ship_group.action.disabled.no_cargo_block": "у класса корабля нет грузового отсека",
"game.inspector.ship_group.action.disabled.no_planet": "планета орбиты не видна",
"game.inspector.ship_group.action.disabled.foreign_planet": "действие доступно только над вашей или ничейной планетой",
"game.inspector.ship_group.action.disabled.empty_cargo": "трюм пуст",
"game.inspector.ship_group.action.disabled.foreign_unload_col": "колонистов нельзя высадить на чужой планете",
"game.inspector.ship_group.action.disabled.no_headroom": "технологии группы уже на вашем расовом уровне",
"game.inspector.ship_group.action.disabled.no_planet_stock": "на планете нет такого ресурса",
"game.inspector.ship_group.action.disabled.full_load": "трюм полностью заполнен",
"game.inspector.ship_group.action.disabled.no_other_races": "нет других нерасправленных рас для передачи",
"game.inspector.ship_group.action.disabled.unknown_class": "класс корабля не найден в отчёте",
"game.inspector.ship_group.action.field.ships": "кораблей (всего {max})",
"game.inspector.ship_group.action.field.cargo": "тип груза",
"game.inspector.ship_group.action.field.quantity": "количество",
"game.inspector.ship_group.action.field.level": "уровень технологии",
"game.inspector.ship_group.action.field.tech": "технология",
"game.inspector.ship_group.action.field.acceptor": "получатель",
"game.inspector.ship_group.action.field.fleet": "имя флота",
"game.inspector.ship_group.action.field.destination": "планета назначения",
"game.inspector.ship_group.action.tech.all": "все блоки",
"game.inspector.ship_group.action.tech.drive": "двигатели",
"game.inspector.ship_group.action.tech.weapons": "оружие",
"game.inspector.ship_group.action.tech.shields": "защита",
"game.inspector.ship_group.action.tech.cargo": "груз",
"game.inspector.ship_group.action.send.pick_prompt": "выберите планету на карте (Esc — отмена)",
"game.inspector.ship_group.action.send.no_destination": "планета не выбрана",
"game.inspector.ship_group.action.modernize.cost": "ожидаемая стоимость: {cost}",
"game.inspector.ship_group.action.modernize.cost_unavailable": "предпросмотр недоступен",
"game.inspector.ship_group.action.dismantle.warning": "группа над чужой планетой везёт колонистов — они погибнут",
"game.inspector.ship_group.action.fleet.create_new": "+ новый флот",
"game.inspector.ship_group.action.invalid.ship_count": "число кораблей должно быть в диапазоне 1…{max}",
"game.inspector.ship_group.action.invalid.quantity": "количество должно быть больше нуля",
"game.inspector.ship_group.action.invalid.level": "уровень должен быть в ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "имя флота не соответствует правилам имён сущностей",
"game.inspector.planet.ship_groups.title": "корабли на орбите",
"game.inspector.planet.ship_groups.row.count": "{count} кораблей",
"game.inspector.planet.ship_groups.row.mass": "масса {mass}",
@@ -1,29 +1,37 @@
<!--
Phase 19 read-only "ship groups stationed here" subsection of the
planet inspector. Phase 19 originally rendered every in-orbit group
as an offset point near its planet on the map, which crowded the
canvas to the point of unreadability. The map now hides on-planet
groups and the planet inspector lists them instead — one row per
group, showing its race, class, ship count, and mass.
Race attribution is best-effort:
"Ship groups stationed here" subsection of the planet inspector.
The map deliberately hides on-planet groups (rendering them as
offset points crowds the canvas), so this list is the player's
view of which fleets sit in this orbit. Race attribution is
best-effort:
- LocalGroup → the player's own race (`localRace` prop).
- OtherGroup on an `other`-kind planet → the planet's owner.
- OtherGroup elsewhere → "foreign" placeholder; the engine's
typed contract does not carry per-group ownership outside
battle rosters.
Rows are intentionally non-interactive in Phase 19. Phase 21+ will
deliver a full ship-groups table view; clicking a row will then
deep-link into that table with a `(planet, race)` filter pre-applied.
Phase 20 makes own-ship rows interactive: clicking a row pivots
the inspector to the corresponding ship-group inspector through
the shared `SelectionStore`. The actions panel mounts on top of
the existing ship-group inspector, so the row is the on-planet
entry point for Send / Load / Modernize / etc. Foreign rows stay
non-interactive — there are no actions to drive against another
race's fleet. Phase 21+ will reuse the same row shape inside the
ship-groups table view with an additional `(planet, race)` filter.
-->
<script lang="ts">
import { getContext } from "svelte";
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
} from "../../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
type Props = {
planet: ReportPlanet;
@@ -33,12 +41,18 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
};
let { planet, localShipGroups, otherShipGroups, localRace }: Props = $props();
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
interface StationedRow {
key: string;
race: string;
class: string;
count: number;
mass: number;
selectable: boolean;
groupId: string | null;
}
const stationedRows: StationedRow[] = $derived.by(() => {
@@ -52,6 +66,8 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
class: g.class,
count: g.count,
mass: g.mass,
selectable: true,
groupId: g.id,
});
}
const foreignRace =
@@ -67,6 +83,8 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
class: g.class,
count: g.count,
mass: g.mass,
selectable: false,
groupId: null,
});
}
return rows;
@@ -75,6 +93,11 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function selectLocalGroup(groupId: string): void {
if (selection === undefined) return;
selection.selectShipGroup({ variant: "local", id: groupId });
}
</script>
{#if stationedRows.length > 0}
@@ -83,20 +106,45 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
<ul class="rows">
{#each stationedRows as row (row.key)}
<li class="row" data-testid="inspector-planet-ship-groups-row">
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
})}
</span>
{#if row.selectable && row.groupId !== null}
{@const groupId = row.groupId}
<button
type="button"
class="select"
data-testid="inspector-planet-ship-groups-select"
onclick={() => selectLocalGroup(groupId)}
>
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
})}
</span>
</button>
{:else}
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
})}
</span>
{/if}
</li>
{/each}
</ul>
@@ -125,11 +173,30 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
gap: 0.2rem;
}
.row {
display: block;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.row > span,
.row > .select {
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.select {
width: 100%;
font: inherit;
text-align: left;
background: transparent;
color: inherit;
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.3rem;
cursor: pointer;
}
.select:hover {
border-color: #2a3150;
background: #0d1224;
}
.race {
font-weight: 600;
@@ -6,17 +6,44 @@ mounted by the in-game shell layout only while the active tool is
`map` so it does not stack on top of the calc / order overlays.
-->
<script lang="ts">
import type { ReportPlanet } from "../../api/game-state";
import type {
ReportLocalFleet,
ReportPlanet,
ShipClassSummary,
} from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import ShipGroup, { type ShipGroupSelection } from "./ship-group.svelte";
type Props = {
selection: ShipGroupSelection | null;
planets: ReportPlanet[];
localShipClass: ShipClassSummary[];
localFleets: ReportLocalFleet[];
otherRaces: string[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
localPlayerWeapons: number;
localPlayerShields: number;
localPlayerCargo: number;
onMap: boolean;
onClose: () => void;
};
let { selection, planets, onMap, onClose }: Props = $props();
let {
selection,
planets,
localShipClass,
localFleets,
otherRaces,
mapWidth,
mapHeight,
localPlayerDrive,
localPlayerWeapons,
localPlayerShields,
localPlayerCargo,
onMap,
onClose,
}: Props = $props();
</script>
{#if selection !== null && onMap}
@@ -34,7 +61,19 @@ mounted by the in-game shell layout only while the active tool is
>
</button>
<ShipGroup {selection} {planets} />
<ShipGroup
{selection}
{planets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
</section>
{/if}
@@ -12,12 +12,15 @@ variant — for Phase 19 the inspector is intentionally read-only.
<script lang="ts">
import type {
ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportUnidentifiedShipGroup,
ShipClassSummary,
} from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import Actions from "./ship-group/actions.svelte";
export type ShipGroupSelection =
| { variant: "local"; group: ReportLocalShipGroup }
@@ -28,8 +31,29 @@ variant — for Phase 19 the inspector is intentionally read-only.
type Props = {
selection: ShipGroupSelection;
planets: ReportPlanet[];
localShipClass?: ShipClassSummary[];
localFleets?: ReportLocalFleet[];
otherRaces?: string[];
mapWidth?: number;
mapHeight?: number;
localPlayerDrive?: number;
localPlayerWeapons?: number;
localPlayerShields?: number;
localPlayerCargo?: number;
};
let { selection, planets }: Props = $props();
let {
selection,
planets,
localShipClass = [],
localFleets = [],
otherRaces = [],
mapWidth = 1,
mapHeight = 1,
localPlayerDrive = 0,
localPlayerWeapons = 0,
localPlayerShields = 0,
localPlayerCargo = 0,
}: Props = $props();
const kindKeyMap: Record<ShipGroupSelection["variant"], TranslationKey> = {
local: "game.inspector.ship_group.kind.local",
@@ -86,6 +110,22 @@ variant — for Phase 19 the inspector is intentionally read-only.
{/if}
</header>
{#if selection.variant === "local"}
<Actions
group={selection.group}
{planets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
{/if}
{#if selection.variant === "local" || selection.variant === "other"}
{@const g = selection.group}
{@const onPlanet = g.origin === null || g.range === null}
File diff suppressed because it is too large Load Diff
@@ -89,12 +89,23 @@ from the Phase 10 stub.
const localPlayerDrive = $derived(
renderedReport?.report?.localPlayerDrive ?? 0,
);
const localPlayerWeapons = $derived(
renderedReport?.report?.localPlayerWeapons ?? 0,
);
const localPlayerShields = $derived(
renderedReport?.report?.localPlayerShields ?? 0,
);
const localPlayerCargo = $derived(
renderedReport?.report?.localPlayerCargo ?? 0,
);
const localShipGroups = $derived(
renderedReport?.report?.localShipGroups ?? [],
);
const otherShipGroups = $derived(
renderedReport?.report?.otherShipGroups ?? [],
);
const localFleets = $derived(renderedReport?.report?.localFleets ?? []);
const otherRaces = $derived(renderedReport?.report?.otherRaces ?? []);
const localRace = $derived(renderedReport?.report?.race ?? "");
</script>
@@ -113,7 +124,19 @@ from the Phase 10 stub.
{localRace}
/>
{:else if selectedShipGroup !== null}
<ShipGroup selection={selectedShipGroup} planets={allPlanets} />
<ShipGroup
selection={selectedShipGroup}
planets={allPlanets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
{:else}
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
@@ -77,9 +77,57 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
return i18n.t("game.sidebar.order.label.ship_class_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";
}