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:
Ilia Denisov
2026-05-10 16:27:55 +02:00
parent f7109af55c
commit 3626998a33
36 changed files with 4033 additions and 89 deletions
+68 -1
View File
@@ -24,7 +24,12 @@
import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load";
import type { CommandStatus, OrderCommand } from "./order-types";
import {
isShipGroupCargo,
isShipGroupUpgradeTech,
type CommandStatus,
type OrderCommand,
} from "./order-types";
import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name";
import { validateShipClass } from "$lib/util/ship-class-validation";
@@ -513,6 +518,68 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
// active production / ship groups. Local validation only
// guards the name shape.
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "breakShipGroup":
// Engine rule (`controller/ship_group.go.breakGroup`):
// quantity must be at least 1 and strictly less than the
// source group size. We do not know the source size here
// (it lives on the report), so the inspector enforces the
// upper bound before emitting; locally we only refuse the
// degenerate cases — non-positive `quantity`, missing or
// equal UUIDs.
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId) || !isUuid(cmd.newGroupId)) return "invalid";
if (cmd.groupId === cmd.newGroupId) return "invalid";
return "valid";
case "sendShipGroup":
// Reach is enforced by the picker before the command lands
// in the draft. Locally we only refuse a degenerate
// destination (the engine uses planet number `0` as the
// "no planet" sentinel; FBS encodes as `int64`, so any
// strictly-positive number is wire-valid).
if (cmd.destinationPlanetNumber <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "loadShipGroup":
// Cargo type and quantity are pre-checked by the inspector
// against the planet stock and the group's free capacity;
// local validation only guards the wire-valid shape.
if (!isShipGroupCargo(cmd.cargo)) return "invalid";
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "unloadShipGroup":
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "upgradeShipGroup":
// Engine rule
// (`controller/ship_group_upgrade.go.shipGroupUpgrade:56`):
// `tech === "ALL"` requires `level === 0`; per-block tech
// requires a strictly positive level. The inspector also
// caps the level to the player's race tech, but the
// engine re-validates server-side.
if (!isShipGroupUpgradeTech(cmd.tech)) return "invalid";
if (cmd.tech === "ALL") {
if (cmd.level !== 0) return "invalid";
} else if (cmd.level <= 0) {
return "invalid";
}
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "dismantleShipGroup":
return isUuid(cmd.groupId) ? "valid" : "invalid";
case "transferShipGroup":
// `acceptor` is a race name; race names follow the same
// entity-name rules as planet/fleet names. The inspector
// restricts the picker to `GameReport.otherRaces`, so a
// locally-valid name is always a real race.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "joinFleetShipGroup":
if (!validateEntityName(cmd.name).ok) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "placeholder":
// Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable.