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
+157
View File
@@ -18,8 +18,18 @@ import {
CommandPlanetRouteSet,
CommandShipClassCreate,
CommandShipClassRemove,
CommandShipGroupBreak,
CommandShipGroupDismantle,
CommandShipGroupJoinFleet,
CommandShipGroupLoad,
CommandShipGroupSend,
CommandShipGroupTransfer,
CommandShipGroupUnload,
CommandShipGroupUpgrade,
PlanetProduction,
PlanetRouteLoadType,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrderGet,
UserGamesOrderGetResponse,
} from "../proto/galaxy/fbs/order";
@@ -27,6 +37,8 @@ import type {
CargoLoadType,
OrderCommand,
ProductionType,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types";
const MESSAGE_TYPE = "user.games.order.get";
@@ -222,6 +234,102 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
name: inner.name() ?? "",
};
}
case CommandPayload.CommandShipGroupBreak: {
const inner = new CommandShipGroupBreak();
item.payload(inner);
return {
kind: "breakShipGroup",
id,
groupId: inner.id() ?? "",
newGroupId: inner.newId() ?? "",
quantity: Number(inner.quantity()),
};
}
case CommandPayload.CommandShipGroupSend: {
const inner = new CommandShipGroupSend();
item.payload(inner);
return {
kind: "sendShipGroup",
id,
groupId: inner.id() ?? "",
destinationPlanetNumber: Number(inner.destination()),
};
}
case CommandPayload.CommandShipGroupLoad: {
const inner = new CommandShipGroupLoad();
item.payload(inner);
const cargo = shipGroupCargoFromFBS(inner.cargo());
if (cargo === null) {
console.warn(
`fetchOrder: skipping CommandShipGroupLoad with unknown cargo enum (${inner.cargo()})`,
);
return null;
}
return {
kind: "loadShipGroup",
id,
groupId: inner.id() ?? "",
cargo,
quantity: inner.quantity(),
};
}
case CommandPayload.CommandShipGroupUnload: {
const inner = new CommandShipGroupUnload();
item.payload(inner);
return {
kind: "unloadShipGroup",
id,
groupId: inner.id() ?? "",
quantity: inner.quantity(),
};
}
case CommandPayload.CommandShipGroupUpgrade: {
const inner = new CommandShipGroupUpgrade();
item.payload(inner);
const tech = shipGroupUpgradeTechFromFBS(inner.tech());
if (tech === null) {
console.warn(
`fetchOrder: skipping CommandShipGroupUpgrade with unknown tech enum (${inner.tech()})`,
);
return null;
}
return {
kind: "upgradeShipGroup",
id,
groupId: inner.id() ?? "",
tech,
level: inner.level(),
};
}
case CommandPayload.CommandShipGroupDismantle: {
const inner = new CommandShipGroupDismantle();
item.payload(inner);
return {
kind: "dismantleShipGroup",
id,
groupId: inner.id() ?? "",
};
}
case CommandPayload.CommandShipGroupTransfer: {
const inner = new CommandShipGroupTransfer();
item.payload(inner);
return {
kind: "transferShipGroup",
id,
groupId: inner.id() ?? "",
acceptor: inner.acceptor() ?? "",
};
}
case CommandPayload.CommandShipGroupJoinFleet: {
const inner = new CommandShipGroupJoinFleet();
item.payload(inner);
return {
kind: "joinFleetShipGroup",
id,
groupId: inner.id() ?? "",
name: inner.name() ?? "",
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
@@ -288,6 +396,55 @@ export function cargoLoadTypeFromFBS(
}
}
/**
* shipGroupCargoFromFBS reverses `shipGroupCargoToFBS` from
* `submit.ts`. `ShipGroupCargo.UNKNOWN` and any out-of-band value
* yield `null` so the caller drops the entry rather than
* fabricating a synthetic cargo type.
*/
export function shipGroupCargoFromFBS(
value: ShipGroupCargo,
): ShipGroupCargoLiteral | null {
switch (value) {
case ShipGroupCargo.COL:
return "COL";
case ShipGroupCargo.CAP:
return "CAP";
case ShipGroupCargo.MAT:
return "MAT";
case ShipGroupCargo.UNKNOWN:
return null;
default:
return null;
}
}
/**
* shipGroupUpgradeTechFromFBS reverses `shipGroupUpgradeTechToFBS`
* from `submit.ts`. `ShipGroupUpgradeTech.UNKNOWN` and any
* out-of-band value yield `null`.
*/
export function shipGroupUpgradeTechFromFBS(
value: ShipGroupUpgradeTech,
): ShipGroupUpgradeTechLiteral | null {
switch (value) {
case ShipGroupUpgradeTech.ALL:
return "ALL";
case ShipGroupUpgradeTech.DRIVE:
return "DRIVE";
case ShipGroupUpgradeTech.WEAPONS:
return "WEAPONS";
case ShipGroupUpgradeTech.SHIELDS:
return "SHIELDS";
case ShipGroupUpgradeTech.CARGO:
return "CARGO";
case ShipGroupUpgradeTech.UNKNOWN:
return null;
default:
return null;
}
}
function decodeError(
payload: Uint8Array,
resultCode: string,