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>
7.6 KiB
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. - 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 atshipGroupLoad: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,UnloadColonistsis 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.
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 asCommandItem.cmdIdon the wire.groupId— the source ship-group's UUID (ornewGroupIdwhen the action is the second half of an implicit split). On the wire it is theidfield 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
BlockUpgradeCostmigrated topkg/calc. The cost formula previously lived ingame/internal/controller/ship_group_upgrade.go. To keep theui/core/calcbridge a wrapper around purepkg/calc/formulas, the function moved topkg/calc/ship.goand the controller now imports it (controller/ship_group_upgrade.go).GameReport.otherRaces. The transfer-to-race picker reads from a newGameReport.otherRaces: string[]field, populated by walkingreport.player[]and excluding the local race plus everyextinctentry. 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.