Files
galaxy-game/ui/docs/ship-group-actions.md
T
Ilia Denisov ac14eaff10 ui/phase-20: pick-first Send + lock after Modernize/Dismantle/Transfer
Send no longer carries a destination control inside the form: a
click on the action drops the inspector straight into map-pick
mode, and the form (ship count + confirm) only mounts after the
player chooses a destination. Cancelling the picker leaves no
form behind.

A queued Modernize / Dismantle / Transfer for a given group
locks every action button on its inspector and surfaces a banner
that points the player at the order list. Cancelling the queued
entry from the order tab releases the lock on the next render —
the derivation watches draft.commands directly. Send / Load /
Unload / Split / Join Fleet do not lock; Send is naturally
followed by an out-of-orbit state at turn cutoff, the rest can
stack legitimately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 17:20:48 +02:00

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

Destructive-command lock

Modernize, Dismantle, and Transfer are state-changing at turn cutoff: the engine moves the group into 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 three 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 (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.

Send, Load, Unload, Split, and Join Fleet do not lock the group: Send is naturally followed by an out-of-orbit state at turn cutoff (the engine's busy check fires next turn anyway), and the other 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.

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.