Files
galaxy-game/ui/frontend/src/lib/inspectors/ship-group/actions.svelte
T
Ilia Denisov 4ad96b0ef7
Tests · UI / test (push) Successful in 2m11s
Tests · UI / test (pull_request) Successful in 2m7s
feat(ui): migrate all view bodies to design tokens (F1b)
Tokenize every remaining component <style> — calculator, order tab,
inspectors, tables, report sections, lobby, auth, mail, battle viewer,
toasts, map overlays. A scripted pass handled the unambiguous core
palette (text/bg/surface/border/accent/danger/muted), the rest were
mapped to the semantic/grey tokens by role.

Remaining colour literals are the documented exceptions only: the
battle-scene SVG data-visualisation palette (fixed dark, like the WebGL
map canvas), overlay scrims (modal / map-canvas), and directional or
deliberate drop shadows. The default theme stays dark until light
coherence is signed off across the views.

Updates ui/docs/design-system.md (migration status + exceptions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:24:02 +02:00

1252 lines
38 KiB
Svelte

<!--
Phase 20 ship-group action panel.
Mounted by `lib/inspectors/ship-group.svelte` for `selection.variant
=== "local"`. Renders eight inline forms — Split, Send, Load, Unload,
Modernize, Dismantle, Transfer, Join Fleet — each gated by the same
state-and-context predicates that the engine enforces server-side. All
real actions either append a single command to the local order draft
or, when the player chose fewer ships than the group holds, prepend
an implicit `breakShipGroup` and route the action at the freshly-
minted sub-group ID.
The component owns the open/closed state for each form (so opening
one closes the others), the ephemeral form fields, and the live
modernize cost preview backed by `core.blockUpgradeCost`.
-->
<script lang="ts">
import { getContext } from "svelte";
import type {
ReportLocalFleet,
ReportLocalShipGroup,
ReportPlanet,
ShipClassSummary,
} from "../../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
import {
MAP_PICK_CONTEXT_KEY,
type MapPickService,
} from "$lib/map-pick.svelte";
import { torusShortestDelta } from "../../../map/math";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
import {
SHIP_GROUP_CARGO_VALUES,
SHIP_GROUP_UPGRADE_TECH_VALUES,
type OrderCommand,
type ShipGroupCargo,
type ShipGroupUpgradeTech,
} from "../../../sync/order-types";
import { validateEntityName } from "$lib/util/entity-name";
type Props = {
group: ReportLocalShipGroup;
planets: ReportPlanet[];
localShipClass: ShipClassSummary[];
localFleets: ReportLocalFleet[];
otherRaces: string[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
localPlayerWeapons: number;
localPlayerShields: number;
localPlayerCargo: number;
};
let {
group,
planets,
localShipClass,
localFleets,
otherRaces,
mapWidth,
mapHeight,
localPlayerDrive,
localPlayerWeapons,
localPlayerShields,
localPlayerCargo,
}: Props = $props();
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const pick = getContext<MapPickService | undefined>(MAP_PICK_CONTEXT_KEY);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
type FormName =
| "split"
| "send"
| "load"
| "unload"
| "modernize"
| "dismantle"
| "transfer"
| "joinFleet";
let openForm: FormName | null = $state(null);
// Per-form ephemeral state. Resets via the form-open helpers below
// so a stale value from a previous selection cannot leak into the
// next one.
let splitShips = $state(1);
let sendShips = $state(1);
let sendDestination: number | null = $state(null);
let sendPicking = $state(false);
let loadShips = $state(1);
let loadCargo: ShipGroupCargo = $state("COL");
let loadQuantity = $state(0);
let unloadShips = $state(1);
let unloadQuantity = $state(0);
let modernizeShips = $state(1);
let modernizeTech: ShipGroupUpgradeTech = $state("ALL");
let modernizeLevel = $state(0);
let dismantleShips = $state(1);
let dismantleConfirmed = $state(false);
let transferShips = $state(1);
let transferAcceptor = $state("");
let joinFleetMode: "existing" | "new" = $state("new");
let joinFleetExisting = $state("");
let joinFleetNew = $state("");
// Reset all per-form state whenever the selected group changes so a
// stale form does not leak across selection switches.
$effect(() => {
void group.id;
openForm = null;
});
// Close any open form the moment the group becomes locked
// (a destructive command landed in the draft from elsewhere —
// e.g. another browser tab editing the same draft, or a
// concurrent action on this inspector). Without this guard a
// pending form would still allow Confirm despite the locked
// banner above it.
$effect(() => {
if (pendingLockingCommand !== null) {
if (sendPicking) {
pick?.cancel();
sendPicking = false;
}
openForm = null;
}
});
const inOrbit = $derived(group.state === "In_Orbit");
const orbitPlanet = $derived(
inOrbit ? (planets.find((p) => p.number === group.destination) ?? null) : null,
);
const shipClass = $derived(
localShipClass.find((sc) => sc.name === group.class) ?? null,
);
const ownPlanet = $derived(orbitPlanet?.kind === "local");
const uninhabitedPlanet = $derived(orbitPlanet?.kind === "uninhabited");
const friendlyPlanet = $derived(ownPlanet || uninhabitedPlanet);
const hasCargoBlock = $derived(shipClass !== null && shipClass.cargo > 0);
const hasDriveBlock = $derived(shipClass !== null && shipClass.drive > 0);
const reach = $derived(40 * localPlayerDrive);
const cargoLoaded = $derived(group.cargo !== "NONE" && group.load > 0);
const carryingColonists = $derived(group.cargo === "COL" && group.load > 0);
const reachableSet = $derived.by(() => {
const ids = new Set<number>();
if (!inOrbit || !hasDriveBlock || reach <= 0 || orbitPlanet === null)
return ids;
for (const candidate of planets) {
if (candidate.number === orbitPlanet.number) continue;
const dx = torusShortestDelta(orbitPlanet.x, candidate.x, mapWidth);
const dy = torusShortestDelta(orbitPlanet.y, candidate.y, mapHeight);
if (Math.hypot(dx, dy) <= reach) ids.add(candidate.number);
}
return ids;
});
const techHeadroom = $derived.by(() => {
// At least one block must have race tech > group tech AND a non-
// zero block mass for modernize to do anything (engine refuses
// when the requested block has zero mass — see
// controller/ship_group_upgrade.go:54).
if (shipClass === null) return false;
const blocks: Array<[number, number, number]> = [
[shipClass.drive, group.tech.drive, localPlayerDrive],
[shipClass.shields, group.tech.shields, localPlayerShields],
[shipClass.cargo, group.tech.cargo, localPlayerCargo],
];
// Weapons block mass requires the bridge.
const core = coreHandle?.core ?? null;
if (core !== null) {
const wm = core.weaponsBlockMass({
weapons: shipClass.weapons,
armament: shipClass.armament,
});
if (wm !== null) blocks.push([wm, group.tech.weapons, localPlayerWeapons]);
} else if (shipClass.weapons > 0) {
// Without Core, fall back to a weak heuristic: if weapons > 0
// the block exists. The engine still re-validates server-
// side so a false-positive only delays the rejection.
blocks.push([1, group.tech.weapons, localPlayerWeapons]);
}
return blocks.some(
([mass, current, race]) => mass > 0 && race > current,
);
});
const planetStock = $derived.by(() => {
if (orbitPlanet === null || orbitPlanet.kind === "unidentified") {
return { COL: 0, CAP: 0, MAT: 0 };
}
return {
COL: orbitPlanet.colonists ?? 0,
CAP: orbitPlanet.industryStockpile ?? 0,
MAT: orbitPlanet.materialsStockpile ?? 0,
};
});
const cargoCapacityPerShip = $derived.by(() => {
const core = coreHandle?.core ?? null;
if (core === null || shipClass === null) return null;
return core.cargoCapacity({
cargo: shipClass.cargo,
cargoTech: localPlayerCargo,
});
});
const groupCapacity = $derived(
cargoCapacityPerShip === null
? null
: cargoCapacityPerShip * group.count,
);
const freeCapacity = $derived(
groupCapacity === null ? null : Math.max(0, groupCapacity - group.load),
);
const fleetsOnSamePlanet = $derived(
localFleets.filter(
(f) =>
f.state === "In_Orbit" &&
f.destination === group.destination &&
f.origin === null,
),
);
function disabledStateTooltip(): string | null {
if (!inOrbit) {
return i18n.t(
"game.inspector.ship_group.action.disabled.not_in_orbit",
{ state: group.state },
);
}
return null;
}
function reasonTooltip(reason: TranslationKey | null): string | null {
return reason === null ? null : i18n.t(reason);
}
// pendingLockingCommand watches the order draft for any send /
// modernize / dismantle / transfer command targeting this group.
// Once the player queues one of those four, every action on the
// group is disabled until the draft entry is removed: each is
// state-changing at turn cutoff (Send → state Launched, Modernize
// → state Upgrade, Transfer → state Transfer, Dismantle → group
// removed), so a follow-up action would race the engine's
// pre-condition check and noisy-fail server-side. The lock
// surfaces the commitment up-front and points the player at the
// order list as the way to release it. Load / Unload / Split /
// Join Fleet stay non-locking — the engine accepts them stacked,
// and Send + the three destructive variants are the only commands
// that flip the group out of `In_Orbit` on the next tick.
const pendingLockingCommand = $derived.by(() => {
if (draft === undefined) return null;
for (const cmd of draft.commands) {
if (
cmd.kind !== "sendShipGroup" &&
cmd.kind !== "upgradeShipGroup" &&
cmd.kind !== "dismantleShipGroup" &&
cmd.kind !== "transferShipGroup"
)
continue;
if (cmd.groupId !== group.id) continue;
const status = draft.statuses[cmd.id];
if (status === "rejected" || status === "invalid") continue;
return cmd;
}
return null;
});
const lockedReason: TranslationKey | null = $derived(
pendingLockingCommand === null
? null
: "game.inspector.ship_group.action.disabled.locked",
);
const lockedKindLabel = $derived.by(() => {
const cmd = pendingLockingCommand;
if (cmd === null) return "";
switch (cmd.kind) {
case "sendShipGroup":
return i18n.t("game.inspector.ship_group.action.locked.kind.send");
case "upgradeShipGroup":
return i18n.t("game.inspector.ship_group.action.locked.kind.modernize");
case "dismantleShipGroup":
return i18n.t("game.inspector.ship_group.action.locked.kind.dismantle");
case "transferShipGroup":
return i18n.t("game.inspector.ship_group.action.locked.kind.transfer");
}
});
const splitDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (group.count < 2) return "game.inspector.ship_group.action.invalid.ship_count";
return null;
});
const sendDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (!hasDriveBlock) return "game.inspector.ship_group.action.disabled.no_drive";
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
if (reachableSet.size === 0) return "game.inspector.ship_group.action.disabled.no_reach";
return null;
});
const loadDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
if (!friendlyPlanet) return "game.inspector.ship_group.action.disabled.foreign_planet";
if (freeCapacity !== null && freeCapacity <= 0)
return "game.inspector.ship_group.action.disabled.full_load";
const stock = planetStock;
if (stock.COL <= 0 && stock.CAP <= 0 && stock.MAT <= 0)
return "game.inspector.ship_group.action.disabled.no_planet_stock";
return null;
});
const unloadDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
if (!cargoLoaded) return "game.inspector.ship_group.action.disabled.empty_cargo";
if (group.cargo === "COL" && !ownPlanet && !uninhabitedPlanet)
return "game.inspector.ship_group.action.disabled.foreign_unload_col";
return null;
});
const modernizeDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
if (!friendlyPlanet) return "game.inspector.ship_group.action.disabled.foreign_planet";
if (shipClass === null) return "game.inspector.ship_group.action.disabled.unknown_class";
if (!techHeadroom) return "game.inspector.ship_group.action.disabled.no_headroom";
return null;
});
const dismantleDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
return null;
});
const transferDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (otherRaces.length === 0)
return "game.inspector.ship_group.action.disabled.no_other_races";
return null;
});
const joinFleetDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
return null;
});
function maxShips(): number {
return group.count;
}
function clampShips(value: number): number {
if (!Number.isFinite(value)) return 1;
const v = Math.floor(value);
if (v < 1) return 1;
if (v > group.count) return group.count;
return v;
}
function openSplit(): void {
closeOthers("split");
splitShips = Math.max(1, Math.min(Math.floor(group.count / 2), group.count - 1));
}
async function openSend(): Promise<void> {
// "Send" is a two-stage flow: the click drops the inspector
// straight into map-pick mode, and the form (ship count +
// confirm) only appears after the player chooses a destination
// or cancels. The destination is therefore never an editable
// control inside the form — picking is the entry point, not a
// sub-step. Re-clicking the action while picking cancels the
// session; re-clicking while the form is open closes it.
if (sendPicking) {
pick?.cancel();
sendPicking = false;
openForm = null;
return;
}
if (openForm === "send") {
openForm = null;
return;
}
openForm = null;
if (pick === undefined || draft === undefined) return;
if (orbitPlanet === null || reachableSet.size === 0) return;
sendShips = group.count;
sendDestination = null;
sendPicking = true;
let picked: number | null = null;
try {
picked = await pick.pick({
sourcePlanetNumber: orbitPlanet.number,
reachableIds: reachableSet,
});
} finally {
sendPicking = false;
}
if (picked === null) return;
sendDestination = picked;
openForm = "send";
}
function openLoad(): void {
closeOthers("load");
loadShips = group.count;
// Initialise cargo type to whatever the group already carries
// so the validator does not require an extra click; if empty,
// default to colonists (the highest priority cargo on routes).
loadCargo = group.cargo === "NONE" ? "COL" : (group.cargo as ShipGroupCargo);
loadQuantity = 0;
}
function openUnload(): void {
closeOthers("unload");
unloadShips = group.count;
unloadQuantity = group.load;
}
function openModernize(): void {
closeOthers("modernize");
modernizeShips = group.count;
modernizeTech = "ALL";
modernizeLevel = 0;
}
function openDismantle(): void {
closeOthers("dismantle");
dismantleShips = group.count;
dismantleConfirmed = false;
}
function openTransfer(): void {
closeOthers("transfer");
transferShips = group.count;
transferAcceptor = otherRaces[0] ?? "";
}
function openJoinFleet(): void {
closeOthers("joinFleet");
const candidates = fleetsOnSamePlanet;
joinFleetMode = candidates.length > 0 ? "existing" : "new";
joinFleetExisting = candidates[0]?.name ?? "";
joinFleetNew = "";
}
function closeOthers(target: FormName): void {
if (openForm === target) {
openForm = null;
return;
}
openForm = target;
}
function cancelForm(): void {
// Cancel any in-flight pick session so the renderer drops the
// dim mask before the form re-opens for a different action.
if (sendPicking) {
pick?.cancel();
sendPicking = false;
}
openForm = null;
}
function actionGroupId(targetShips: number): {
groupId: string;
breakCommand: OrderCommand | null;
} {
// When the action targets fewer ships than the group holds, the
// engine needs a fresh sub-group; we mint its UUID up front and
// emit a `breakShipGroup` command that carves it out before the
// real action runs against the new id.
if (targetShips >= group.count) {
return { groupId: group.id, breakCommand: null };
}
const newGroupId = crypto.randomUUID();
return {
groupId: newGroupId,
breakCommand: {
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: group.id,
newGroupId,
quantity: targetShips,
},
};
}
async function emit(
breakCommand: OrderCommand | null,
action: OrderCommand,
): Promise<void> {
if (draft === undefined) return;
if (breakCommand !== null) await draft.add(breakCommand);
await draft.add(action);
}
async function confirmSplit(): Promise<void> {
const ships = clampShips(splitShips);
if (ships < 1 || ships >= group.count || draft === undefined) return;
await draft.add({
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: group.id,
newGroupId: crypto.randomUUID(),
quantity: ships,
});
openForm = null;
}
async function confirmSend(): Promise<void> {
if (sendDestination === null || draft === undefined) return;
const ships = clampShips(sendShips);
const { groupId, breakCommand } = actionGroupId(ships);
await emit(breakCommand, {
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId,
destinationPlanetNumber: sendDestination,
});
openForm = null;
}
async function confirmLoad(): Promise<void> {
const ships = clampShips(loadShips);
const quantity = Number(loadQuantity);
if (!(quantity > 0) || draft === undefined) return;
const { groupId, breakCommand } = actionGroupId(ships);
await emit(breakCommand, {
kind: "loadShipGroup",
id: crypto.randomUUID(),
groupId,
cargo: loadCargo,
quantity,
});
openForm = null;
}
async function confirmUnload(): Promise<void> {
const ships = clampShips(unloadShips);
const quantity = Number(unloadQuantity);
if (!(quantity > 0) || draft === undefined) return;
const { groupId, breakCommand } = actionGroupId(ships);
await emit(breakCommand, {
kind: "unloadShipGroup",
id: crypto.randomUUID(),
groupId,
quantity,
});
openForm = null;
}
async function confirmModernize(): Promise<void> {
if (draft === undefined) return;
const ships = clampShips(modernizeShips);
const tech = modernizeTech;
const level = tech === "ALL" ? 0 : Number(modernizeLevel);
if (tech !== "ALL" && !(level > 0)) return;
const { groupId, breakCommand } = actionGroupId(ships);
await emit(breakCommand, {
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId,
tech,
level,
});
openForm = null;
}
async function confirmDismantle(): Promise<void> {
if (draft === undefined) return;
const ships = clampShips(dismantleShips);
const subset = ships < group.count;
const colonistsOverForeign = !ownPlanet && !uninhabitedPlanet && subset
? group.cargo === "COL" && group.load > 0
: !ownPlanet && !uninhabitedPlanet && carryingColonists;
if (colonistsOverForeign && !dismantleConfirmed) {
dismantleConfirmed = true;
return;
}
const { groupId, breakCommand } = actionGroupId(ships);
await emit(breakCommand, {
kind: "dismantleShipGroup",
id: crypto.randomUUID(),
groupId,
});
openForm = null;
}
async function confirmTransfer(): Promise<void> {
if (draft === undefined) return;
const ships = clampShips(transferShips);
const acceptor = transferAcceptor.trim();
if (acceptor === "") return;
const { groupId, breakCommand } = actionGroupId(ships);
await emit(breakCommand, {
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId,
acceptor,
});
openForm = null;
}
async function confirmJoinFleet(): Promise<void> {
if (draft === undefined) return;
const name = joinFleetMode === "existing" ? joinFleetExisting : joinFleetNew;
if (!validateEntityName(name).ok) return;
await draft.add({
kind: "joinFleetShipGroup",
id: crypto.randomUUID(),
groupId: group.id,
name,
});
openForm = null;
}
const modernizeCostPreview = $derived.by(() => {
const core = coreHandle?.core ?? null;
if (core === null || shipClass === null) return null;
const ships = clampShips(modernizeShips);
const targets: Record<"DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO", number> =
modernizeTech === "ALL"
? {
DRIVE: localPlayerDrive,
WEAPONS: localPlayerWeapons,
SHIELDS: localPlayerShields,
CARGO: localPlayerCargo,
}
: {
DRIVE: 0,
WEAPONS: 0,
SHIELDS: 0,
CARGO: 0,
[modernizeTech]: Number(modernizeLevel) || 0,
};
const weapons = core.weaponsBlockMass({
weapons: shipClass.weapons,
armament: shipClass.armament,
});
const blocks: Array<[number, number, number]> = [
[shipClass.drive, group.tech.drive, targets.DRIVE],
[weapons ?? 0, group.tech.weapons, targets.WEAPONS],
[shipClass.shields, group.tech.shields, targets.SHIELDS],
[shipClass.cargo, group.tech.cargo, targets.CARGO],
];
let perShip = 0;
for (const [mass, current, target] of blocks) {
if (target <= 0) continue;
perShip += core.blockUpgradeCost({
blockMass: mass,
currentTech: current,
targetTech: target,
});
}
if (perShip <= 0) return 0;
return perShip * ships;
});
const cargoLabelKey: Record<ShipGroupCargo, TranslationKey> = {
COL: "game.inspector.ship_group.cargo.col",
CAP: "game.inspector.ship_group.cargo.cap",
MAT: "game.inspector.ship_group.cargo.mat",
};
const techLabelKey: Record<ShipGroupUpgradeTech, TranslationKey> = {
ALL: "game.inspector.ship_group.action.tech.all",
DRIVE: "game.inspector.ship_group.action.tech.drive",
WEAPONS: "game.inspector.ship_group.action.tech.weapons",
SHIELDS: "game.inspector.ship_group.action.tech.shields",
CARGO: "game.inspector.ship_group.action.tech.cargo",
};
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
</script>
<section class="actions" data-testid="inspector-ship-group-actions">
{#if pendingLockingCommand !== null}
<p class="locked" data-testid="inspector-ship-group-actions-locked">
{i18n.t("game.inspector.ship_group.action.locked.banner", {
command: lockedKindLabel,
})}
</p>
{/if}
<div class="row">
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-split"
disabled={splitDisabledReason !== null || draft === undefined}
title={reasonTooltip(splitDisabledReason) ?? ""}
onclick={openSplit}
>
{i18n.t("game.inspector.ship_group.action.split")}
</button>
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-send"
disabled={sendDisabledReason !== null || draft === undefined}
title={reasonTooltip(sendDisabledReason) ?? ""}
onclick={() => void openSend()}
>
{i18n.t("game.inspector.ship_group.action.send")}
</button>
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-load"
disabled={loadDisabledReason !== null || draft === undefined}
title={reasonTooltip(loadDisabledReason) ?? ""}
onclick={openLoad}
>
{i18n.t("game.inspector.ship_group.action.load")}
</button>
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-unload"
disabled={unloadDisabledReason !== null || draft === undefined}
title={reasonTooltip(unloadDisabledReason) ?? ""}
onclick={openUnload}
>
{i18n.t("game.inspector.ship_group.action.unload")}
</button>
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-modernize"
disabled={modernizeDisabledReason !== null || draft === undefined}
title={reasonTooltip(modernizeDisabledReason) ?? ""}
onclick={openModernize}
>
{i18n.t("game.inspector.ship_group.action.modernize")}
</button>
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-dismantle"
disabled={dismantleDisabledReason !== null || draft === undefined}
title={reasonTooltip(dismantleDisabledReason) ?? ""}
onclick={openDismantle}
>
{i18n.t("game.inspector.ship_group.action.dismantle")}
</button>
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-transfer"
disabled={transferDisabledReason !== null || draft === undefined}
title={reasonTooltip(transferDisabledReason) ?? ""}
onclick={openTransfer}
>
{i18n.t("game.inspector.ship_group.action.transfer")}
</button>
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-join-fleet"
disabled={joinFleetDisabledReason !== null || draft === undefined}
title={reasonTooltip(joinFleetDisabledReason) ?? ""}
onclick={openJoinFleet}
>
{i18n.t("game.inspector.ship_group.action.join_fleet")}
</button>
</div>
{#if openForm === "split"}
<form class="form" data-testid="inspector-ship-group-form-split" onsubmit={(e) => { e.preventDefault(); void confirmSplit(); }}>
<label>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
type="number"
min="1"
max={String(group.count - 1)}
step="1"
data-testid="inspector-ship-group-form-split-ships"
bind:value={splitShips}
/>
</label>
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-split-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
</button>
<button
type="submit"
class="primary"
data-testid="inspector-ship-group-form-split-confirm"
disabled={splitShips < 1 || splitShips >= group.count}
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
</div>
</form>
{/if}
{#if openForm === "send" && sendDestination !== null}
<form class="form" data-testid="inspector-ship-group-form-send" onsubmit={(e) => { e.preventDefault(); void confirmSend(); }}>
<p class="destination-readonly">
<span class="label">{i18n.t("game.inspector.ship_group.action.field.destination")}</span>
<span data-testid="inspector-ship-group-form-send-destination">
{planets.find((p) => p.number === sendDestination)?.name ?? `#${sendDestination}`}
</span>
</p>
<label>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
type="number"
min="1"
max={String(group.count)}
step="1"
data-testid="inspector-ship-group-form-send-ships"
bind:value={sendShips}
/>
</label>
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-send-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
</button>
<button
type="submit"
class="primary"
data-testid="inspector-ship-group-form-send-confirm"
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
</div>
</form>
{/if}
{#if sendPicking}
<p class="hint" data-testid="inspector-ship-group-form-send-pick-prompt">
{i18n.t("game.inspector.ship_group.action.send.pick_prompt")}
</p>
{/if}
{#if openForm === "load"}
<form class="form" data-testid="inspector-ship-group-form-load" onsubmit={(e) => { e.preventDefault(); void confirmLoad(); }}>
<label>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
type="number"
min="1"
max={String(group.count)}
step="1"
data-testid="inspector-ship-group-form-load-ships"
bind:value={loadShips}
/>
</label>
<label>
{i18n.t("game.inspector.ship_group.action.field.cargo")}
<select
data-testid="inspector-ship-group-form-load-cargo"
bind:value={loadCargo}
disabled={group.cargo !== "NONE"}
>
{#each SHIP_GROUP_CARGO_VALUES as value (value)}
<option {value} disabled={planetStock[value] <= 0}>
{i18n.t(cargoLabelKey[value])}
</option>
{/each}
</select>
</label>
<label>
{i18n.t("game.inspector.ship_group.action.field.quantity")}
<input
type="number"
min="0"
step="0.01"
data-testid="inspector-ship-group-form-load-quantity"
bind:value={loadQuantity}
/>
</label>
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-load-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
</button>
<button
type="submit"
class="primary"
data-testid="inspector-ship-group-form-load-confirm"
disabled={!(loadQuantity > 0)}
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
</div>
</form>
{/if}
{#if openForm === "unload"}
<form class="form" data-testid="inspector-ship-group-form-unload" onsubmit={(e) => { e.preventDefault(); void confirmUnload(); }}>
<label>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
type="number"
min="1"
max={String(group.count)}
step="1"
data-testid="inspector-ship-group-form-unload-ships"
bind:value={unloadShips}
/>
</label>
<label>
{i18n.t("game.inspector.ship_group.action.field.quantity")}
<input
type="number"
min="0"
step="0.01"
data-testid="inspector-ship-group-form-unload-quantity"
bind:value={unloadQuantity}
/>
</label>
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-unload-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
</button>
<button
type="submit"
class="primary"
data-testid="inspector-ship-group-form-unload-confirm"
disabled={!(unloadQuantity > 0)}
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
</div>
</form>
{/if}
{#if openForm === "modernize"}
<form class="form" data-testid="inspector-ship-group-form-modernize" onsubmit={(e) => { e.preventDefault(); void confirmModernize(); }}>
<label>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
type="number"
min="1"
max={String(group.count)}
step="1"
data-testid="inspector-ship-group-form-modernize-ships"
bind:value={modernizeShips}
/>
</label>
<label>
{i18n.t("game.inspector.ship_group.action.field.tech")}
<select
data-testid="inspector-ship-group-form-modernize-tech"
bind:value={modernizeTech}
>
{#each SHIP_GROUP_UPGRADE_TECH_VALUES as value (value)}
<option {value}>{i18n.t(techLabelKey[value])}</option>
{/each}
</select>
</label>
{#if modernizeTech !== "ALL"}
<label>
{i18n.t("game.inspector.ship_group.action.field.level")}
<input
type="number"
min="0"
step="0.01"
data-testid="inspector-ship-group-form-modernize-level"
bind:value={modernizeLevel}
/>
</label>
{/if}
<p class="preview" data-testid="inspector-ship-group-form-modernize-cost">
{#if modernizeCostPreview === null}
{i18n.t("game.inspector.ship_group.action.modernize.cost_unavailable")}
{:else}
{i18n.t("game.inspector.ship_group.action.modernize.cost", {
cost: formatNumber(modernizeCostPreview),
})}
{/if}
</p>
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-modernize-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
</button>
<button
type="submit"
class="primary"
data-testid="inspector-ship-group-form-modernize-confirm"
disabled={modernizeTech !== "ALL" && !(modernizeLevel > 0)}
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
</div>
</form>
{/if}
{#if openForm === "dismantle"}
<form class="form" data-testid="inspector-ship-group-form-dismantle" onsubmit={(e) => { e.preventDefault(); void confirmDismantle(); }}>
<label>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
type="number"
min="1"
max={String(group.count)}
step="1"
data-testid="inspector-ship-group-form-dismantle-ships"
bind:value={dismantleShips}
/>
</label>
{#if !ownPlanet && !uninhabitedPlanet && carryingColonists}
<p class="warning" data-testid="inspector-ship-group-form-dismantle-warning">
{i18n.t("game.inspector.ship_group.action.dismantle.warning")}
</p>
{/if}
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-dismantle-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
</button>
<button
type="submit"
class="primary"
data-testid="inspector-ship-group-form-dismantle-confirm"
>
{#if !ownPlanet && !uninhabitedPlanet && carryingColonists && !dismantleConfirmed}
{i18n.t("game.inspector.ship_group.action.confirm_destroy")}
{:else}
{i18n.t("game.inspector.ship_group.action.confirm")}
{/if}
</button>
</div>
</form>
{/if}
{#if openForm === "transfer"}
<form class="form" data-testid="inspector-ship-group-form-transfer" onsubmit={(e) => { e.preventDefault(); void confirmTransfer(); }}>
<label>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
type="number"
min="1"
max={String(group.count)}
step="1"
data-testid="inspector-ship-group-form-transfer-ships"
bind:value={transferShips}
/>
</label>
<label>
{i18n.t("game.inspector.ship_group.action.field.acceptor")}
<select
data-testid="inspector-ship-group-form-transfer-acceptor"
bind:value={transferAcceptor}
>
{#each otherRaces as race (race)}
<option value={race}>{race}</option>
{/each}
</select>
</label>
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-transfer-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
</button>
<button
type="submit"
class="primary"
data-testid="inspector-ship-group-form-transfer-confirm"
disabled={transferAcceptor === ""}
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
</div>
</form>
{/if}
{#if openForm === "joinFleet"}
<form class="form" data-testid="inspector-ship-group-form-join-fleet" onsubmit={(e) => { e.preventDefault(); void confirmJoinFleet(); }}>
{#if fleetsOnSamePlanet.length > 0}
<label>
<input
type="radio"
name="fleet-mode"
value="existing"
bind:group={joinFleetMode}
data-testid="inspector-ship-group-form-join-fleet-mode-existing"
/>
{i18n.t("game.inspector.ship_group.action.field.fleet")}
</label>
{#if joinFleetMode === "existing"}
<select
data-testid="inspector-ship-group-form-join-fleet-existing"
bind:value={joinFleetExisting}
>
{#each fleetsOnSamePlanet as fleet (fleet.name)}
<option value={fleet.name}>{fleet.name}</option>
{/each}
</select>
{/if}
{/if}
<label>
<input
type="radio"
name="fleet-mode"
value="new"
bind:group={joinFleetMode}
data-testid="inspector-ship-group-form-join-fleet-mode-new"
/>
{i18n.t("game.inspector.ship_group.action.fleet.create_new")}
</label>
{#if joinFleetMode === "new"}
<input
type="text"
data-testid="inspector-ship-group-form-join-fleet-new"
bind:value={joinFleetNew}
placeholder={i18n.t("game.inspector.ship_group.action.field.fleet")}
/>
{/if}
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-join-fleet-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
</button>
<button
type="submit"
class="primary"
data-testid="inspector-ship-group-form-join-fleet-confirm"
disabled={(joinFleetMode === "existing" ? joinFleetExisting : joinFleetNew) === ""}
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
</div>
</form>
{/if}
{#if disabledStateTooltip() !== null && openForm === null}
<p class="hint" data-testid="inspector-ship-group-actions-state-hint">
{disabledStateTooltip()}
</p>
{/if}
</section>
<style>
.actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.action {
font: inherit;
font-size: 0.85rem;
padding: 0.2rem 0.55rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.action:not(:disabled):hover {
color: var(--color-text);
border-color: var(--color-accent);
}
.action:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.form {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: var(--color-surface-raised);
}
.form label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.form input[type="number"],
.form input[type="text"],
.form select {
font: inherit;
padding: 0.25rem 0.4rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.form .destination-readonly {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
font-size: 0.85rem;
}
.form .destination-readonly .label {
color: var(--color-text-muted);
}
.form-actions {
display: flex;
gap: 0.4rem;
margin-top: 0.25rem;
}
.form-actions button {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.65rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.form-actions button.primary {
color: var(--color-text);
border-color: var(--color-accent);
}
.form-actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.preview {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.warning {
margin: 0;
font-size: 0.85rem;
color: var(--color-warning);
}
.locked {
margin: 0;
padding: 0.4rem 0.55rem;
font-size: 0.85rem;
color: var(--color-text-muted);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.hint {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
</style>