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:
@@ -0,0 +1,151 @@
|
||||
# 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 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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user