# 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.
