# Ship-group inspector actions Phase 20 turns the read-only ship-group inspector (`ui/frontend/src/lib/inspectors/ship-group.svelte`) into an interactive command source for the player's own groups in orbit. This document is the running spec for the actions panel (`ui/frontend/src/lib/inspectors/ship-group/actions.svelte`): which actions exist, what gates each one, how partial-ship operations split a group on the fly, and what the modernize cost preview shows. ## Reaching a group The map renderer hides on-planet ship groups to avoid crowding the canvas. The player reaches an own on-planet group through the planet inspector's **stationed ship groups** subsection: clicking a row pivots the `SelectionStore` to the matching `shipGroup.local` ref, the sidebar swaps from the planet inspector to the ship-group inspector, and the actions panel mounts. In-flight (in-space) groups appear as map primitives and can be selected by clicking the rendered point. ## Action surface | Action | Implicit-split? | Partial input | FBS payload | Engine reference | | ----------- | :-------------: | ------------- | ---------------------------- | ----------------------------------------------------- | | Split | — | ships count | `CommandShipGroupBreak` | `controller/ship_group.go.breakGroup` | | Send | yes | ships count + destination | `CommandShipGroupSend` | `controller/ship_group_send.go.shipGroupSend` | | Load | yes | ships count + cargo + quantity | `CommandShipGroupLoad` | `controller/ship_group.go.shipGroupLoad` | | Unload | yes | ships count + quantity | `CommandShipGroupUnload` | `controller/ship_group.go.shipGroupUnload` | | Modernize | yes | ships count + tech + level | `CommandShipGroupUpgrade` | `controller/ship_group_upgrade.go.shipGroupUpgrade` | | Dismantle | yes | ships count + foreign-COL confirm | `CommandShipGroupDismantle` | `controller/ship_group.go.shipGroupDismantle` | | Transfer | yes | ships count + acceptor race | `CommandShipGroupTransfer` | `controller/ship_group.go.shipGroupTransfer` | | Join Fleet | — | fleet name (existing or new) | `CommandShipGroupJoinFleet` | `controller/fleet.go.ShipGroupJoinFleet` | "Implicit-split" means the inspector accepts a number of ships `M < N` and emits a `CommandShipGroupBreak(id, newId, M)` command *before* the action command, then targets the action at the freshly-minted `newId`. The FBS schema only carries a per-ship `quantity` on `CommandShipGroupBreak`; every other ship-group command applies to the whole group, so the implicit-split pattern is the only way to act on a subset without forcing the player to pre-split manually. Acceptance criteria: "splitting a group of N into K and N-K results in two valid commands" — that is exactly the (Break, Action) pair this pipeline emits. Split and Join Fleet do not accept a partial ship count: Split *is* the break operation; Join Fleet attaches the whole group atomically (the engine handles a partial detach by issuing Split first, which the player drives explicitly). ## Disabled-state rules The inspector mirrors the engine's pre-conditions per command (see the references column above) and surfaces each as a disabled-button tooltip. Any state other than `In_Orbit` disables every action with `ships are busy ({state})`. Per-action gates: - **Send**: requires the ship class to have a non-zero drive block (`controller/ship_group_send.go:32`); the picker pre-filters destinations by reach (`localPlayerDrive * 40`), so a valid pick is always within range. With no reachable planet, the action is disabled with the "no planets in drive range" tooltip. Click drops the inspector straight into map-pick mode; the form (ship count + confirm) appears only after the player chooses a destination — there is no destination control inside the form, so cancelling the picker leaves the inspector untouched. - **Load**: requires the orbit planet to be owned by the player or unowned (`controller/ship_group.go:215`) and the ship class to have a cargo block (`shipGroupLoad:220`). The dropdown is pinned to the existing cargo type when the group is already partially loaded (the engine refuses cargo-type changes at `shipGroupLoad:223`). - **Unload**: requires non-empty cargo. Colonists (`COL`) over a foreign planet are blocked (`shipGroupUnload:283`), with the matching tooltip in the disabled state. - **Modernize**: requires the orbit planet to be own/unowned (`shipGroupUpgrade:29`) and at least one block whose race tech exceeds the group tech (otherwise nothing can be upgraded). - **Dismantle**: always available in orbit. When the orbit is over a foreign planet AND the group carries colonists, the inline form replaces the normal "confirm" with "confirm — colonists die"; the player has to click twice to commit (engine reference `shipGroupDismantle:177-179` — over a foreign planet, `UnloadColonists` is not called and the cargo is lost). - **Transfer**: requires at least one non-extinct race other than the local player (sourced from `GameReport.otherRaces`). - **Join Fleet**: existing-fleet picker is restricted to fleets in the same orbit (`fleet.go:135-137`); creating a new fleet always works. ## State-changing-command lock `Send`, `Modernize`, `Dismantle`, and `Transfer` are *state-changing* at turn cutoff: the engine moves the group into `StateLaunched`, `StateUpgrade`, removes it, or marks it `StateTransfer` respectively. Issuing a follow-up action against the same group during the same draft window would race the engine's pre-condition check, so the inspector locks the group as soon as one of the four commands lands in the draft for that `groupId`: - every action button on the group's inspector becomes disabled with the "an order is already queued" tooltip; - a banner above the buttons row names the queued command (send / modernize / dismantle / transfer) and tells the player to cancel it in the order list to issue something else; - removing the queued entry from the order tab releases the lock on the next render — the derivation watches `draft.commands` directly. Load, Unload, Split, and Join Fleet do not lock the group: those four can stack legitimately during the same window. The group continues to appear in the planet inspector's stationed-ship list while locked — the player can still navigate to the inspector to read the state and find the order to cancel. ## Map overlays for in-flight and pending-Send groups Two dashed-line overlays run on the same renderer layer as the cargo-route arrows: - **Yellow dashed track** for own ship groups currently in hyperspace, drawn from the origin planet to the destination (matches the colour of the in-space group point so the eye reads both as one entity). - **Green dashed track** for every wire-valid `sendShipGroup` command in the order draft — drawn from the source group's orbit planet to the chosen destination. Disappears when the command is removed from the order tab, when the engine flips it `rejected` / `invalid`, or when the group has left orbit (the Send was applied and the in-space track replaces it). Both tracks are wrap-aware via `torusShortestDelta` so a route across the seam takes the wrap. Neither participates in hit-test — the player still picks ship groups by clicking the group point, not the track. ## Modernize cost preview The form's preview line calls `core.blockUpgradeCost({ blockMass, currentTech, targetTech })` once per ship block (drive, weapons, shields, cargo) and sums the per-ship totals before multiplying by the targeted ship count. Block masses come from the player's `ShipClassSummary` for the group's class: - Drive / shields / cargo block mass = the corresponding ship- class field (raw value). - Weapons block mass = `core.weaponsBlockMass({ weapons, armament })` (Phase 18 bridge); returns null on the invalid weapons/armament pairing, in which case the row contributes zero. For `tech === "ALL"` every block whose mass is non-zero contributes against the player's race tech as the target. For per-block tech (`DRIVE` / `WEAPONS` / `SHIELDS` / `CARGO`) only the chosen block contributes, with `level` as the target. The preview hides when the form is invalid (`tech !== "ALL"` with non-positive `level`) or when `Core` has not yet booted — the bridge call is the only source of truth, so we surface "preview unavailable" rather than fall back to a JS re-implementation that could drift from the engine. ## Wire shape Every emitted command carries: - `id` — client-minted UUID (`crypto.randomUUID()`), used by the order draft for status tracking; mirrored as `CommandItem.cmdId` on the wire. - `groupId` — the source ship-group's UUID (or `newGroupId` when the action is the second half of an implicit split). On the wire it is the `id` field of every ship-group payload type. Per-action additional fields are documented on the `OrderCommand` union in `ui/frontend/src/sync/order-types.ts` next to the JSDoc for each variant. ## Decisions baked into Phase 20 - **`BlockUpgradeCost` migrated to `pkg/calc`**. The cost formula previously lived in `game/internal/controller/ship_group_upgrade.go`. To keep the `ui/core/calc` bridge a wrapper around pure `pkg/calc/` formulas, the function moved to `pkg/calc/ship.go` and the controller now imports it (`controller/ship_group_upgrade.go`). - **`GameReport.otherRaces`**. The transfer-to-race picker reads from a new `GameReport.otherRaces: string[]` field, populated by walking `report.player[]` and excluding the local race plus every `extinct` entry. Phase 22 (Races View) reuses the same field. - **Stationed-ship rows are clickable**. The map deliberately hides on-planet groups; the planet inspector's stationed-ship rows now pivot the selection to the corresponding ship-group variant so the actions panel is reachable from the standard click flow.