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
+35
View File
@@ -272,6 +272,18 @@ export interface GameReport {
incomingShipGroups: ReportIncomingShipGroup[];
unidentifiedShipGroups: ReportUnidentifiedShipGroup[];
localFleets: ReportLocalFleet[];
/**
* otherRaces lists the names of every non-extinct race other than
* the local player, sorted alphabetically. Drawn from the
* `report.player[]` block in the FBS report (each `Player` row
* carries an `extinct` flag). The ship-group inspector consumes
* this list for the "transfer to race" picker; Phase 22's Races
* View reuses the same field so the read shape is stable across
* stages. Empty when the report has no `player` block (boot
* state, history-mode snapshots) or when the local player is the
* only non-extinct race.
*/
otherRaces: string[];
}
export async function fetchGameReport(
@@ -405,6 +417,7 @@ function decodeReport(report: Report): GameReport {
const raceName = report.race() ?? "";
const routes = decodeReportRoutes(report);
const localTech = findLocalPlayerTech(report, raceName);
const otherRaces = collectOtherRaces(report, raceName);
const localShipGroups = decodeLocalShipGroups(report);
const otherShipGroups = decodeOtherShipGroups(report);
const incomingShipGroups = decodeIncomingShipGroups(report);
@@ -429,6 +442,7 @@ function decodeReport(report: Report): GameReport {
incomingShipGroups,
unidentifiedShipGroups,
localFleets,
otherRaces,
};
}
@@ -705,6 +719,27 @@ function findLocalPlayerTech(
return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
}
/**
* collectOtherRaces walks the `report.player[]` block and returns
* the alphabetically-sorted names of every non-extinct race other
* than the local player. Used by `GameReport.otherRaces` to back the
* ship-group inspector's transfer-to-race picker (Phase 20) and the
* Races View list (Phase 22).
*/
function collectOtherRaces(report: Report, raceName: string): string[] {
const out: string[] = [];
for (let i = 0; i < report.playerLength(); i++) {
const player = report.player(i);
if (player === null) continue;
if (player.extinct()) continue;
const name = player.name() ?? "";
if (name === "" || name === raceName) continue;
out.push(name);
}
out.sort((a, b) => a.localeCompare(b));
return out;
}
/**
* uuidToHiLo splits the canonical 36-character UUID string
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
+17
View File
@@ -102,6 +102,7 @@ interface SyntheticPlayer {
weapons: number;
shields: number;
cargo: number;
extinct?: boolean;
}
interface SyntheticShipGroup {
@@ -269,9 +270,25 @@ function decodeSyntheticReport(json: unknown): GameReport {
incomingShipGroups,
unidentifiedShipGroups,
localFleets,
otherRaces: collectOtherRacesFromSynthetic(root, race),
};
}
function collectOtherRacesFromSynthetic(
root: SyntheticReportRoot,
raceName: string,
): string[] {
const out: string[] = [];
for (const player of root.player ?? []) {
if (player.extinct === true) continue;
const name = typeof player.name === "string" ? player.name : "";
if (name === "" || name === raceName) continue;
out.push(name);
}
out.sort((a, b) => a.localeCompare(b));
return out;
}
function toShipGroupTech(raw: Record<string, number> | undefined): ShipGroupTech {
const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 };
if (raw === undefined || raw === null) return out;
+56
View File
@@ -194,6 +194,14 @@ const en = {
"game.sidebar.order.label.cargo_route_remove": "remove {loadType} route from planet {source}",
"game.sidebar.order.label.ship_class_create": "design ship class {name}",
"game.sidebar.order.label.ship_class_remove": "remove ship class {name}",
"game.sidebar.order.label.ship_group_break": "split group {group} → {quantity} ships into new group",
"game.sidebar.order.label.ship_group_send": "send group {group} → planet {destination}",
"game.sidebar.order.label.ship_group_load": "load {cargo} × {quantity} onto group {group}",
"game.sidebar.order.label.ship_group_unload": "unload × {quantity} from group {group}",
"game.sidebar.order.label.ship_group_upgrade": "modernize group {group} {tech} → {level}",
"game.sidebar.order.label.ship_group_dismantle": "dismantle group {group}",
"game.sidebar.order.label.ship_group_transfer": "transfer group {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}",
"game.table.ship_classes.title": "ship classes",
"game.table.ship_classes.column.name": "name",
"game.table.ship_classes.column.drive": "drive",
@@ -276,6 +284,54 @@ const en = {
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "no data — only the radar blip is known",
"game.inspector.ship_group.action.split": "split",
"game.inspector.ship_group.action.send": "send",
"game.inspector.ship_group.action.load": "load",
"game.inspector.ship_group.action.unload": "unload",
"game.inspector.ship_group.action.modernize": "modernize",
"game.inspector.ship_group.action.dismantle": "dismantle",
"game.inspector.ship_group.action.transfer": "transfer",
"game.inspector.ship_group.action.join_fleet": "join fleet",
"game.inspector.ship_group.action.confirm": "confirm",
"game.inspector.ship_group.action.cancel": "cancel",
"game.inspector.ship_group.action.confirm_destroy": "confirm — colonists die",
"game.inspector.ship_group.action.disabled.not_in_orbit": "ships are busy ({state}); only orbiting groups accept actions",
"game.inspector.ship_group.action.disabled.no_reach": "no planets are within drive range",
"game.inspector.ship_group.action.disabled.no_drive": "this ship class has no drive block",
"game.inspector.ship_group.action.disabled.no_cargo_block": "this ship class has no cargo block",
"game.inspector.ship_group.action.disabled.no_planet": "the orbit planet is not visible",
"game.inspector.ship_group.action.disabled.foreign_planet": "this action is only available on your own or unowned planets",
"game.inspector.ship_group.action.disabled.empty_cargo": "the group is empty",
"game.inspector.ship_group.action.disabled.foreign_unload_col": "colonists cannot be unloaded over a foreign planet",
"game.inspector.ship_group.action.disabled.no_headroom": "the group's tech is already at your race level",
"game.inspector.ship_group.action.disabled.no_planet_stock": "the planet has no available stock of this cargo",
"game.inspector.ship_group.action.disabled.full_load": "the group is fully loaded",
"game.inspector.ship_group.action.disabled.no_other_races": "no other non-extinct races to transfer to",
"game.inspector.ship_group.action.disabled.unknown_class": "the ship class is missing from the report",
"game.inspector.ship_group.action.field.ships": "ships ({max} total)",
"game.inspector.ship_group.action.field.cargo": "cargo type",
"game.inspector.ship_group.action.field.quantity": "quantity",
"game.inspector.ship_group.action.field.level": "tech level",
"game.inspector.ship_group.action.field.tech": "tech",
"game.inspector.ship_group.action.field.acceptor": "acceptor",
"game.inspector.ship_group.action.field.fleet": "fleet name",
"game.inspector.ship_group.action.field.destination": "destination planet",
"game.inspector.ship_group.action.tech.all": "all blocks",
"game.inspector.ship_group.action.tech.drive": "drive",
"game.inspector.ship_group.action.tech.weapons": "weapons",
"game.inspector.ship_group.action.tech.shields": "shields",
"game.inspector.ship_group.action.tech.cargo": "cargo",
"game.inspector.ship_group.action.send.pick_prompt": "click a planet on the map (Esc to cancel)",
"game.inspector.ship_group.action.send.no_destination": "no destination chosen",
"game.inspector.ship_group.action.modernize.cost": "estimated cost: {cost}",
"game.inspector.ship_group.action.modernize.cost_unavailable": "cost preview unavailable",
"game.inspector.ship_group.action.dismantle.warning": "the group is over a foreign planet with colonists aboard — they will die",
"game.inspector.ship_group.action.fleet.create_new": "+ new fleet",
"game.inspector.ship_group.action.invalid.ship_count": "ships must be in the range 1…{max}",
"game.inspector.ship_group.action.invalid.quantity": "quantity must be greater than zero",
"game.inspector.ship_group.action.invalid.level": "level must be in ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "fleet name does not match the entity-name rules",
"game.inspector.planet.ship_groups.title": "stationed ship groups",
"game.inspector.planet.ship_groups.row.count": "{count} ships",
"game.inspector.planet.ship_groups.row.mass": "mass {mass}",
+56
View File
@@ -195,6 +195,14 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.label.cargo_route_remove": "удалить маршрут {loadType} с планеты {source}",
"game.sidebar.order.label.ship_class_create": "сконструировать класс корабля {name}",
"game.sidebar.order.label.ship_class_remove": "удалить класс корабля {name}",
"game.sidebar.order.label.ship_group_break": "разделить группу {group} → новая группа из {quantity} кораблей",
"game.sidebar.order.label.ship_group_send": "отправить группу {group} → планета {destination}",
"game.sidebar.order.label.ship_group_load": "загрузить {cargo} × {quantity} в группу {group}",
"game.sidebar.order.label.ship_group_unload": "выгрузить × {quantity} из группы {group}",
"game.sidebar.order.label.ship_group_upgrade": "модернизация группы {group} {tech} → {level}",
"game.sidebar.order.label.ship_group_dismantle": "разобрать группу {group}",
"game.sidebar.order.label.ship_group_transfer": "передать группу {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
"game.table.ship_classes.title": "классы кораблей",
"game.table.ship_classes.column.name": "название",
"game.table.ship_classes.column.drive": "двигатель",
@@ -277,6 +285,54 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты",
"game.inspector.ship_group.action.split": "разделить",
"game.inspector.ship_group.action.send": "отправить",
"game.inspector.ship_group.action.load": "загрузить",
"game.inspector.ship_group.action.unload": "выгрузить",
"game.inspector.ship_group.action.modernize": "модернизировать",
"game.inspector.ship_group.action.dismantle": "разобрать",
"game.inspector.ship_group.action.transfer": "передать",
"game.inspector.ship_group.action.join_fleet": "во флот",
"game.inspector.ship_group.action.confirm": "подтвердить",
"game.inspector.ship_group.action.cancel": "отмена",
"game.inspector.ship_group.action.confirm_destroy": "подтвердить — колонисты погибнут",
"game.inspector.ship_group.action.disabled.not_in_orbit": "корабли заняты ({state}); действия доступны только на орбите",
"game.inspector.ship_group.action.disabled.no_reach": "в радиусе двигателей нет планет",
"game.inspector.ship_group.action.disabled.no_drive": "у класса корабля нет блока двигателей",
"game.inspector.ship_group.action.disabled.no_cargo_block": "у класса корабля нет грузового отсека",
"game.inspector.ship_group.action.disabled.no_planet": "планета орбиты не видна",
"game.inspector.ship_group.action.disabled.foreign_planet": "действие доступно только над вашей или ничейной планетой",
"game.inspector.ship_group.action.disabled.empty_cargo": "трюм пуст",
"game.inspector.ship_group.action.disabled.foreign_unload_col": "колонистов нельзя высадить на чужой планете",
"game.inspector.ship_group.action.disabled.no_headroom": "технологии группы уже на вашем расовом уровне",
"game.inspector.ship_group.action.disabled.no_planet_stock": "на планете нет такого ресурса",
"game.inspector.ship_group.action.disabled.full_load": "трюм полностью заполнен",
"game.inspector.ship_group.action.disabled.no_other_races": "нет других нерасправленных рас для передачи",
"game.inspector.ship_group.action.disabled.unknown_class": "класс корабля не найден в отчёте",
"game.inspector.ship_group.action.field.ships": "кораблей (всего {max})",
"game.inspector.ship_group.action.field.cargo": "тип груза",
"game.inspector.ship_group.action.field.quantity": "количество",
"game.inspector.ship_group.action.field.level": "уровень технологии",
"game.inspector.ship_group.action.field.tech": "технология",
"game.inspector.ship_group.action.field.acceptor": "получатель",
"game.inspector.ship_group.action.field.fleet": "имя флота",
"game.inspector.ship_group.action.field.destination": "планета назначения",
"game.inspector.ship_group.action.tech.all": "все блоки",
"game.inspector.ship_group.action.tech.drive": "двигатели",
"game.inspector.ship_group.action.tech.weapons": "оружие",
"game.inspector.ship_group.action.tech.shields": "защита",
"game.inspector.ship_group.action.tech.cargo": "груз",
"game.inspector.ship_group.action.send.pick_prompt": "выберите планету на карте (Esc — отмена)",
"game.inspector.ship_group.action.send.no_destination": "планета не выбрана",
"game.inspector.ship_group.action.modernize.cost": "ожидаемая стоимость: {cost}",
"game.inspector.ship_group.action.modernize.cost_unavailable": "предпросмотр недоступен",
"game.inspector.ship_group.action.dismantle.warning": "группа над чужой планетой везёт колонистов — они погибнут",
"game.inspector.ship_group.action.fleet.create_new": "+ новый флот",
"game.inspector.ship_group.action.invalid.ship_count": "число кораблей должно быть в диапазоне 1…{max}",
"game.inspector.ship_group.action.invalid.quantity": "количество должно быть больше нуля",
"game.inspector.ship_group.action.invalid.level": "уровень должен быть в ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "имя флота не соответствует правилам имён сущностей",
"game.inspector.planet.ship_groups.title": "корабли на орбите",
"game.inspector.planet.ship_groups.row.count": "{count} кораблей",
"game.inspector.planet.ship_groups.row.mass": "масса {mass}",
@@ -1,29 +1,37 @@
<!--
Phase 19 read-only "ship groups stationed here" subsection of the
planet inspector. Phase 19 originally rendered every in-orbit group
as an offset point near its planet on the map, which crowded the
canvas to the point of unreadability. The map now hides on-planet
groups and the planet inspector lists them instead — one row per
group, showing its race, class, ship count, and mass.
Race attribution is best-effort:
"Ship groups stationed here" subsection of the planet inspector.
The map deliberately hides on-planet groups (rendering them as
offset points crowds the canvas), so this list is the player's
view of which fleets sit in this orbit. Race attribution is
best-effort:
- LocalGroup → the player's own race (`localRace` prop).
- OtherGroup on an `other`-kind planet → the planet's owner.
- OtherGroup elsewhere → "foreign" placeholder; the engine's
typed contract does not carry per-group ownership outside
battle rosters.
Rows are intentionally non-interactive in Phase 19. Phase 21+ will
deliver a full ship-groups table view; clicking a row will then
deep-link into that table with a `(planet, race)` filter pre-applied.
Phase 20 makes own-ship rows interactive: clicking a row pivots
the inspector to the corresponding ship-group inspector through
the shared `SelectionStore`. The actions panel mounts on top of
the existing ship-group inspector, so the row is the on-planet
entry point for Send / Load / Modernize / etc. Foreign rows stay
non-interactive — there are no actions to drive against another
race's fleet. Phase 21+ will reuse the same row shape inside the
ship-groups table view with an additional `(planet, race)` filter.
-->
<script lang="ts">
import { getContext } from "svelte";
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
} from "../../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
type Props = {
planet: ReportPlanet;
@@ -33,12 +41,18 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
};
let { planet, localShipGroups, otherShipGroups, localRace }: Props = $props();
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
interface StationedRow {
key: string;
race: string;
class: string;
count: number;
mass: number;
selectable: boolean;
groupId: string | null;
}
const stationedRows: StationedRow[] = $derived.by(() => {
@@ -52,6 +66,8 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
class: g.class,
count: g.count,
mass: g.mass,
selectable: true,
groupId: g.id,
});
}
const foreignRace =
@@ -67,6 +83,8 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
class: g.class,
count: g.count,
mass: g.mass,
selectable: false,
groupId: null,
});
}
return rows;
@@ -75,6 +93,11 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function selectLocalGroup(groupId: string): void {
if (selection === undefined) return;
selection.selectShipGroup({ variant: "local", id: groupId });
}
</script>
{#if stationedRows.length > 0}
@@ -83,20 +106,45 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
<ul class="rows">
{#each stationedRows as row (row.key)}
<li class="row" data-testid="inspector-planet-ship-groups-row">
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
})}
</span>
{#if row.selectable && row.groupId !== null}
{@const groupId = row.groupId}
<button
type="button"
class="select"
data-testid="inspector-planet-ship-groups-select"
onclick={() => selectLocalGroup(groupId)}
>
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
})}
</span>
</button>
{:else}
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
})}
</span>
{/if}
</li>
{/each}
</ul>
@@ -125,11 +173,30 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
gap: 0.2rem;
}
.row {
display: block;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.row > span,
.row > .select {
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.select {
width: 100%;
font: inherit;
text-align: left;
background: transparent;
color: inherit;
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.3rem;
cursor: pointer;
}
.select:hover {
border-color: #2a3150;
background: #0d1224;
}
.race {
font-weight: 600;
@@ -6,17 +6,44 @@ mounted by the in-game shell layout only while the active tool is
`map` so it does not stack on top of the calc / order overlays.
-->
<script lang="ts">
import type { ReportPlanet } from "../../api/game-state";
import type {
ReportLocalFleet,
ReportPlanet,
ShipClassSummary,
} from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import ShipGroup, { type ShipGroupSelection } from "./ship-group.svelte";
type Props = {
selection: ShipGroupSelection | null;
planets: ReportPlanet[];
localShipClass: ShipClassSummary[];
localFleets: ReportLocalFleet[];
otherRaces: string[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
localPlayerWeapons: number;
localPlayerShields: number;
localPlayerCargo: number;
onMap: boolean;
onClose: () => void;
};
let { selection, planets, onMap, onClose }: Props = $props();
let {
selection,
planets,
localShipClass,
localFleets,
otherRaces,
mapWidth,
mapHeight,
localPlayerDrive,
localPlayerWeapons,
localPlayerShields,
localPlayerCargo,
onMap,
onClose,
}: Props = $props();
</script>
{#if selection !== null && onMap}
@@ -34,7 +61,19 @@ mounted by the in-game shell layout only while the active tool is
>
</button>
<ShipGroup {selection} {planets} />
<ShipGroup
{selection}
{planets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
</section>
{/if}
@@ -12,12 +12,15 @@ variant — for Phase 19 the inspector is intentionally read-only.
<script lang="ts">
import type {
ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportUnidentifiedShipGroup,
ShipClassSummary,
} from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import Actions from "./ship-group/actions.svelte";
export type ShipGroupSelection =
| { variant: "local"; group: ReportLocalShipGroup }
@@ -28,8 +31,29 @@ variant — for Phase 19 the inspector is intentionally read-only.
type Props = {
selection: ShipGroupSelection;
planets: ReportPlanet[];
localShipClass?: ShipClassSummary[];
localFleets?: ReportLocalFleet[];
otherRaces?: string[];
mapWidth?: number;
mapHeight?: number;
localPlayerDrive?: number;
localPlayerWeapons?: number;
localPlayerShields?: number;
localPlayerCargo?: number;
};
let { selection, planets }: Props = $props();
let {
selection,
planets,
localShipClass = [],
localFleets = [],
otherRaces = [],
mapWidth = 1,
mapHeight = 1,
localPlayerDrive = 0,
localPlayerWeapons = 0,
localPlayerShields = 0,
localPlayerCargo = 0,
}: Props = $props();
const kindKeyMap: Record<ShipGroupSelection["variant"], TranslationKey> = {
local: "game.inspector.ship_group.kind.local",
@@ -86,6 +110,22 @@ variant — for Phase 19 the inspector is intentionally read-only.
{/if}
</header>
{#if selection.variant === "local"}
<Actions
group={selection.group}
{planets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
{/if}
{#if selection.variant === "local" || selection.variant === "other"}
{@const g = selection.group}
{@const onPlanet = g.origin === null || g.range === null}
File diff suppressed because it is too large Load Diff
@@ -89,12 +89,23 @@ from the Phase 10 stub.
const localPlayerDrive = $derived(
renderedReport?.report?.localPlayerDrive ?? 0,
);
const localPlayerWeapons = $derived(
renderedReport?.report?.localPlayerWeapons ?? 0,
);
const localPlayerShields = $derived(
renderedReport?.report?.localPlayerShields ?? 0,
);
const localPlayerCargo = $derived(
renderedReport?.report?.localPlayerCargo ?? 0,
);
const localShipGroups = $derived(
renderedReport?.report?.localShipGroups ?? [],
);
const otherShipGroups = $derived(
renderedReport?.report?.otherShipGroups ?? [],
);
const localFleets = $derived(renderedReport?.report?.localFleets ?? []);
const otherRaces = $derived(renderedReport?.report?.otherRaces ?? []);
const localRace = $derived(renderedReport?.report?.race ?? "");
</script>
@@ -113,7 +124,19 @@ from the Phase 10 stub.
{localRace}
/>
{:else if selectedShipGroup !== null}
<ShipGroup selection={selectedShipGroup} planets={allPlanets} />
<ShipGroup
selection={selectedShipGroup}
planets={allPlanets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
{:else}
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
@@ -77,9 +77,57 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
return i18n.t("game.sidebar.order.label.ship_class_remove", {
name: cmd.name,
});
case "breakShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_break", {
group: shortGroupId(cmd.groupId),
quantity: String(cmd.quantity),
});
case "sendShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_send", {
group: shortGroupId(cmd.groupId),
destination: String(cmd.destinationPlanetNumber),
});
case "loadShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_load", {
group: shortGroupId(cmd.groupId),
cargo: cmd.cargo,
quantity: String(cmd.quantity),
});
case "unloadShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_unload", {
group: shortGroupId(cmd.groupId),
quantity: String(cmd.quantity),
});
case "upgradeShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_upgrade", {
group: shortGroupId(cmd.groupId),
tech: cmd.tech,
level: String(cmd.level),
});
case "dismantleShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_dismantle", {
group: shortGroupId(cmd.groupId),
});
case "transferShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_transfer", {
group: shortGroupId(cmd.groupId),
acceptor: cmd.acceptor,
});
case "joinFleetShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_join_fleet", {
group: shortGroupId(cmd.groupId),
fleet: cmd.name,
});
}
}
// Short identifier for the order-tab so the human-readable label
// stays glanceable; the full UUID is still in the underlying
// command and visible in the inspector overlay.
function shortGroupId(uuid: string): string {
return uuid.length > 8 ? uuid.slice(0, 8) : uuid;
}
function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft";
}
+17
View File
@@ -73,6 +73,12 @@ export interface CarryingMassInput {
cargoTech: number;
}
export interface BlockUpgradeCostInput {
blockMass: number;
currentTech: number;
targetTech: number;
}
export interface Core {
/**
* signRequest returns the canonical signing input bytes for a v1
@@ -157,6 +163,17 @@ export interface Core {
* cargoCapacity.
*/
carryingMass(input: CarryingMassInput): number;
/**
* blockUpgradeCost wraps `pkg/calc/ship.go.BlockUpgradeCost`:
* production cost of moving one ship block from currentTech to
* targetTech, scaled by the block mass and a constant 10. Returns
* 0 when blockMass is zero or targetTech is not above currentTech.
* Phase 20's ship-group inspector calls this once per block
* (drive, weapons, shields, cargo) to render the modernize cost
* preview.
*/
blockUpgradeCost(input: BlockUpgradeCostInput): number;
}
export type CoreLoader = () => Promise<Core>;
+5
View File
@@ -9,6 +9,7 @@
// served from `static/core.wasm`.
import type {
BlockUpgradeCostInput,
CargoCapacityInput,
CarryingMassInput,
Core,
@@ -50,6 +51,7 @@ interface GalaxyCoreBridge {
speed(input: SpeedInput): number;
cargoCapacity(input: CargoCapacityInput): number;
carryingMass(input: CarryingMassInput): number;
blockUpgradeCost(input: BlockUpgradeCostInput): number;
}
interface BridgeRequestFields {
@@ -210,6 +212,9 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
carryingMass(input: CarryingMassInput): number {
return bridge.carryingMass(input);
},
blockUpgradeCost(input: BlockUpgradeCostInput): number {
return bridge.blockUpgradeCost(input);
},
};
}
@@ -180,12 +180,27 @@ fresh.
const inspectorLocalDrive = $derived(
renderedReport.report?.localPlayerDrive ?? 0,
);
const inspectorLocalWeapons = $derived(
renderedReport.report?.localPlayerWeapons ?? 0,
);
const inspectorLocalShields = $derived(
renderedReport.report?.localPlayerShields ?? 0,
);
const inspectorLocalCargo = $derived(
renderedReport.report?.localPlayerCargo ?? 0,
);
const inspectorLocalShipGroups = $derived(
renderedReport.report?.localShipGroups ?? [],
);
const inspectorOtherShipGroups = $derived(
renderedReport.report?.otherShipGroups ?? [],
);
const inspectorLocalFleets = $derived(
renderedReport.report?.localFleets ?? [],
);
const inspectorOtherRaces = $derived(
renderedReport.report?.otherRaces ?? [],
);
const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");
// Reveal the inspector whenever a new planet selection lands.
@@ -340,6 +355,15 @@ fresh.
<ShipGroupSheet
selection={selectedShipGroup}
planets={inspectorPlanets}
{localShipClass}
localFleets={inspectorLocalFleets}
otherRaces={inspectorOtherRaces}
mapWidth={inspectorMapWidth}
mapHeight={inspectorMapHeight}
localPlayerDrive={inspectorLocalDrive}
localPlayerWeapons={inspectorLocalWeapons}
localPlayerShields={inspectorLocalShields}
localPlayerCargo={inspectorLocalCargo}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
+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.
+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,
+212 -1
View File
@@ -166,6 +166,209 @@ export interface RemoveShipClassCommand {
readonly name: string;
}
/**
* ShipGroupCargo mirrors the engine `ShipGroupCargo` enum
* (`pkg/schema/fbs/order.fbs`). Three values: colonists, capital
* (industry crates), and materials. Empty (`EMP`) is a route-level
* concept (`PlanetRouteLoadType`) and is not a valid cargo type for a
* ship-group load command — the FBS enum deliberately omits it.
*/
export type ShipGroupCargo = "COL" | "CAP" | "MAT";
/**
* SHIP_GROUP_CARGO_VALUES is the canonical tuple of `ShipGroupCargo`
* literals. Used by validators and the FBS converters in
* `submit.ts` and `order-load.ts` to narrow incoming strings.
*/
export const SHIP_GROUP_CARGO_VALUES = [
"COL",
"CAP",
"MAT",
] as const satisfies readonly ShipGroupCargo[];
/**
* isShipGroupCargo narrows an arbitrary string to the
* `ShipGroupCargo` union.
*/
export function isShipGroupCargo(value: string): value is ShipGroupCargo {
return (SHIP_GROUP_CARGO_VALUES as readonly string[]).includes(value);
}
/**
* ShipGroupUpgradeTech mirrors the engine `ShipGroupUpgradeTech`
* enum (`pkg/schema/fbs/order.fbs`): `ALL` upgrades every applicable
* block to the player's current race tech (level argument must be 0
* — see `controller/ship_group_upgrade.go:56`); the four per-block
* values upgrade exactly that block to the requested level.
*/
export type ShipGroupUpgradeTech =
| "ALL"
| "DRIVE"
| "WEAPONS"
| "SHIELDS"
| "CARGO";
/**
* SHIP_GROUP_UPGRADE_TECH_VALUES is the canonical tuple of
* `ShipGroupUpgradeTech` literals. The order matches the FBS enum.
*/
export const SHIP_GROUP_UPGRADE_TECH_VALUES = [
"ALL",
"DRIVE",
"WEAPONS",
"SHIELDS",
"CARGO",
] as const satisfies readonly ShipGroupUpgradeTech[];
/**
* isShipGroupUpgradeTech narrows an arbitrary string to the
* `ShipGroupUpgradeTech` union.
*/
export function isShipGroupUpgradeTech(
value: string,
): value is ShipGroupUpgradeTech {
return (SHIP_GROUP_UPGRADE_TECH_VALUES as readonly string[]).includes(value);
}
/**
* BreakShipGroupCommand splits a player-owned ship group into two:
* the original keeps `originalCount - quantity` ships and a new group
* with `newGroupId` carries `quantity`. Used both as a stand-alone
* action and as the implicit prelude to Send / Load / Unload /
* Modernize / Dismantle / Transfer when the player picks fewer than
* all ships. Engine rules (`controller/ship_group.go.breakGroup`):
* source group must be `StateInOrbit`, `quantity` must be in `[1,
* originalCount - 1]` for a real split. The new group carries a
* proportional slice of the cargo and starts unattached to any fleet.
*/
export interface BreakShipGroupCommand {
readonly kind: "breakShipGroup";
readonly id: string;
readonly groupId: string;
readonly newGroupId: string;
readonly quantity: number;
}
/**
* SendShipGroupCommand launches a player-owned ship group toward a
* destination planet. Engine rules
* (`controller/ship_group_send.go.shipGroupSend`): group must be
* `StateInOrbit`; ship class must have a non-zero drive block; the
* destination must be within the player's current
* `FlightDistance() = localPlayerDrive * 40` (torus-aware).
* The picker filters the planet list before emitting, so a draft
* entry that survives validation is always reachable at submit time.
*/
export interface SendShipGroupCommand {
readonly kind: "sendShipGroup";
readonly id: string;
readonly groupId: string;
readonly destinationPlanetNumber: number;
}
/**
* LoadShipGroupCommand loads cargo of one of the three ship-group
* cargo types onto a player-owned group. Engine rules
* (`controller/ship_group.go.shipGroupLoad`): group must be
* `StateInOrbit`; planet must be owned by the player or unowned;
* ship class must have a non-zero cargo block; the existing cargo
* type (if any) must equal `cargo`; `quantity` is bounded by the
* planet's stock and the group's free capacity. The inspector
* pre-checks each of these so a draft entry is always wire-valid.
*/
export interface LoadShipGroupCommand {
readonly kind: "loadShipGroup";
readonly id: string;
readonly groupId: string;
readonly cargo: ShipGroupCargo;
readonly quantity: number;
}
/**
* UnloadShipGroupCommand drops cargo from a player-owned group at
* its current orbit. Engine rules
* (`controller/ship_group.go.shipGroupUnload`): group must be
* `StateInOrbit`; ship class must have a non-zero cargo block; group
* must currently carry cargo. Colonists (`COL`) cannot be unloaded
* over a foreign planet — the inspector disables the action with a
* tooltip in that case. The cargo type is implicit (whatever the
* group is carrying); only `quantity` is sent on the wire.
*/
export interface UnloadShipGroupCommand {
readonly kind: "unloadShipGroup";
readonly id: string;
readonly groupId: string;
readonly quantity: number;
}
/**
* UpgradeShipGroupCommand schedules a tech upgrade for a player-
* owned group at its current orbit. Engine rules
* (`controller/ship_group_upgrade.go.shipGroupUpgrade`): group must
* be `StateInOrbit`; the planet must be owned by the player or
* unowned; for per-block techs the requested `level` must be in
* `(group.tech, race.tech]`; for `tech === "ALL"` the `level` must
* be 0 (the engine fans the upgrade out to every block whose mass is
* non-zero). The inspector renders a live cost preview through
* `core.blockUpgradeCost` to make the production cost visible before
* the player commits.
*/
export interface UpgradeShipGroupCommand {
readonly kind: "upgradeShipGroup";
readonly id: string;
readonly groupId: string;
readonly tech: ShipGroupUpgradeTech;
readonly level: number;
}
/**
* DismantleShipGroupCommand deconstructs a player-owned group at its
* current orbit, returning the empty mass to the planet's materials
* stockpile. Engine rules (`controller/ship_group.go.shipGroupDismantle`):
* group must be `StateInOrbit`; over a foreign planet, colonists
* (`COL`) on board are *lost* — the inspector surfaces an explicit
* two-step confirmation in that case before adding the command to
* the draft.
*/
export interface DismantleShipGroupCommand {
readonly kind: "dismantleShipGroup";
readonly id: string;
readonly groupId: string;
}
/**
* TransferShipGroupCommand hands a player-owned group to another
* race. Engine rules (`controller/ship_group.go.shipGroupTransfer`):
* acceptor must be a different, non-extinct race; group must not
* already be in `StateTransfer`. The inspector restricts the
* acceptor picker to `GameReport.otherRaces` (non-extinct ≠ self),
* so a draft entry always names a real race.
*/
export interface TransferShipGroupCommand {
readonly kind: "transferShipGroup";
readonly id: string;
readonly groupId: string;
readonly acceptor: string;
}
/**
* JoinFleetShipGroupCommand attaches a player-owned group to a fleet
* (creating it on the fly if no fleet by that name exists). Engine
* rules (`controller/fleet.go.ShipGroupJoinFleet`): group must be
* `StateInOrbit`; the target fleet, when it already exists, must
* sit in the same orbit as the group; `name` must pass
* `validateEntityName`. Because the engine handles the whole-group
* attach atomically (no per-ship counter), this command does not
* support implicit-split — the inspector exposes Split as a
* separate explicit action when partial detachment is desired.
*/
export interface JoinFleetShipGroupCommand {
readonly kind: "joinFleetShipGroup";
readonly id: string;
readonly groupId: string;
readonly name: string;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
@@ -179,7 +382,15 @@ export type OrderCommand =
| SetCargoRouteCommand
| RemoveCargoRouteCommand
| CreateShipClassCommand
| RemoveShipClassCommand;
| RemoveShipClassCommand
| BreakShipGroupCommand
| SendShipGroupCommand
| LoadShipGroupCommand
| UnloadShipGroupCommand
| UpgradeShipGroupCommand
| DismantleShipGroupCommand
| TransferShipGroupCommand
| JoinFleetShipGroupCommand;
/**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
+158
View File
@@ -33,8 +33,18 @@ import {
CommandPlanetRouteSet,
CommandShipClassCreate,
CommandShipClassRemove,
CommandShipGroupBreak,
CommandShipGroupDismantle,
CommandShipGroupJoinFleet,
CommandShipGroupLoad,
CommandShipGroupSend,
CommandShipGroupTransfer,
CommandShipGroupUnload,
CommandShipGroupUpgrade,
PlanetProduction,
PlanetRouteLoadType,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrder,
UserGamesOrderResponse,
} from "../proto/galaxy/fbs/order";
@@ -42,6 +52,8 @@ import type {
CargoLoadType,
OrderCommand,
ProductionType,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types";
const MESSAGE_TYPE = "user.games.order";
@@ -222,6 +234,109 @@ function encodeCommandPayload(
payloadOffset: offset,
};
}
case "breakShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const newIdOffset = builder.createString(cmd.newGroupId);
CommandShipGroupBreak.startCommandShipGroupBreak(builder);
CommandShipGroupBreak.addId(builder, idOffset);
CommandShipGroupBreak.addNewId(builder, newIdOffset);
CommandShipGroupBreak.addQuantity(builder, BigInt(cmd.quantity));
const offset = CommandShipGroupBreak.endCommandShipGroupBreak(builder);
return {
payloadType: CommandPayload.CommandShipGroupBreak,
payloadOffset: offset,
};
}
case "sendShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const offset = CommandShipGroupSend.createCommandShipGroupSend(
builder,
idOffset,
BigInt(cmd.destinationPlanetNumber),
);
return {
payloadType: CommandPayload.CommandShipGroupSend,
payloadOffset: offset,
};
}
case "loadShipGroup": {
const idOffset = builder.createString(cmd.groupId);
CommandShipGroupLoad.startCommandShipGroupLoad(builder);
CommandShipGroupLoad.addId(builder, idOffset);
CommandShipGroupLoad.addCargo(builder, shipGroupCargoToFBS(cmd.cargo));
CommandShipGroupLoad.addQuantity(builder, cmd.quantity);
const offset = CommandShipGroupLoad.endCommandShipGroupLoad(builder);
return {
payloadType: CommandPayload.CommandShipGroupLoad,
payloadOffset: offset,
};
}
case "unloadShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const offset = CommandShipGroupUnload.createCommandShipGroupUnload(
builder,
idOffset,
cmd.quantity,
);
return {
payloadType: CommandPayload.CommandShipGroupUnload,
payloadOffset: offset,
};
}
case "upgradeShipGroup": {
const idOffset = builder.createString(cmd.groupId);
CommandShipGroupUpgrade.startCommandShipGroupUpgrade(builder);
CommandShipGroupUpgrade.addId(builder, idOffset);
CommandShipGroupUpgrade.addTech(
builder,
shipGroupUpgradeTechToFBS(cmd.tech),
);
CommandShipGroupUpgrade.addLevel(builder, cmd.level);
const offset = CommandShipGroupUpgrade.endCommandShipGroupUpgrade(builder);
return {
payloadType: CommandPayload.CommandShipGroupUpgrade,
payloadOffset: offset,
};
}
case "dismantleShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const offset =
CommandShipGroupDismantle.createCommandShipGroupDismantle(
builder,
idOffset,
);
return {
payloadType: CommandPayload.CommandShipGroupDismantle,
payloadOffset: offset,
};
}
case "transferShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const acceptorOffset = builder.createString(cmd.acceptor);
const offset = CommandShipGroupTransfer.createCommandShipGroupTransfer(
builder,
idOffset,
acceptorOffset,
);
return {
payloadType: CommandPayload.CommandShipGroupTransfer,
payloadOffset: offset,
};
}
case "joinFleetShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const nameOffset = builder.createString(cmd.name);
const offset =
CommandShipGroupJoinFleet.createCommandShipGroupJoinFleet(
builder,
idOffset,
nameOffset,
);
return {
payloadType: CommandPayload.CommandShipGroupJoinFleet,
payloadOffset: offset,
};
}
case "placeholder":
throw new SubmitError(
"invalid_request",
@@ -277,6 +392,49 @@ export function cargoLoadTypeToFBS(value: CargoLoadType): PlanetRouteLoadType {
}
}
/**
* shipGroupCargoToFBS converts the wire-stable `ShipGroupCargo`
* literal to the FlatBuffers enum value. Mirrors the engine
* `ShipGroupCargo` enum (`pkg/schema/fbs/order.fbs`). The FBS enum
* carries an `UNKNOWN` zero value as the default; the encoder always
* emits one of the three real values.
*/
export function shipGroupCargoToFBS(
value: ShipGroupCargoLiteral,
): ShipGroupCargo {
switch (value) {
case "COL":
return ShipGroupCargo.COL;
case "CAP":
return ShipGroupCargo.CAP;
case "MAT":
return ShipGroupCargo.MAT;
}
}
/**
* shipGroupUpgradeTechToFBS converts the wire-stable
* `ShipGroupUpgradeTech` literal to the FlatBuffers enum value.
* Mirrors the engine `ShipGroupUpgradeTech` enum
* (`pkg/schema/fbs/order.fbs`).
*/
export function shipGroupUpgradeTechToFBS(
value: ShipGroupUpgradeTechLiteral,
): ShipGroupUpgradeTech {
switch (value) {
case "ALL":
return ShipGroupUpgradeTech.ALL;
case "DRIVE":
return ShipGroupUpgradeTech.DRIVE;
case "WEAPONS":
return ShipGroupUpgradeTech.WEAPONS;
case "SHIELDS":
return ShipGroupUpgradeTech.SHIELDS;
case "CARGO":
return ShipGroupUpgradeTech.CARGO;
}
}
function decodeOrderResponse(
payload: Uint8Array,
commands: OrderCommand[],
Binary file not shown.
@@ -0,0 +1,254 @@
// Phase 20 end-to-end coverage for the ship-group Send action.
// Loads a synthetic report with a local group of three Frontier
// ships in orbit over Earth and a reachable destination planet
// (Mars), opens the inspector by clicking the rendered group,
// drives the Send form (asking for 2 ships out of 3), picks Mars
// through the map-pick service, and asserts the resulting order
// draft has both an implicit `breakShipGroup` and the targeted
// `sendShipGroup` whose `groupId` references the freshly minted
// sub-group ID. The synthetic flow uses a non-UUID game id, so
// the auto-sync pipeline skips the network — the assertion
// targets the in-memory draft via the order-tab UI.
import { expect, test, type Page } from "@playwright/test";
const SESSION_ID = "phase-20-send-session";
interface DebugSurface {
ready?: boolean;
loadSession(): Promise<unknown>;
clearSession?(): Promise<void>;
setDeviceSessionId(id: string): Promise<void>;
}
declare global {
interface Window {
__galaxyDebug?: DebugSurface;
}
}
const SYNTHETIC_FIXTURE = {
turn: 1,
mapWidth: 200,
mapHeight: 200,
mapPlanets: 2,
race: "Earthlings",
player: [
{
name: "Earthlings",
drive: 5,
weapons: 0,
shields: 0,
cargo: 1,
population: 1000,
industry: 1000,
planets: 1,
relation: "-",
votes: 0,
extinct: false,
},
{
name: "Aliens",
drive: 4,
weapons: 2,
shields: 1,
cargo: 1,
population: 800,
industry: 800,
planets: 1,
relation: "-",
votes: 0,
extinct: false,
},
],
localPlanet: [
{
number: 1,
name: "Earth",
x: 100,
y: 100,
size: 1000,
population: 1000,
industry: 1000,
resources: 10,
production: "Capital",
capital: 0,
material: 0,
colonists: 100,
freeIndustry: 1000,
},
],
otherPlanet: [
{
number: 2,
name: "Mars",
x: 110,
y: 100,
size: 800,
population: 800,
industry: 800,
resources: 8,
production: "Capital",
capital: 0,
material: 0,
colonists: 80,
freeIndustry: 800,
owner: "Aliens",
},
],
uninhabitedPlanet: [],
unidentifiedPlanet: [],
localShipClass: [
{
name: "Frontier",
drive: 5,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
mass: 12,
},
],
localGroup: [
{
id: "11111111-2222-3333-4444-555555555555",
number: 3,
class: "Frontier",
tech: { drive: 5, weapons: 0, shields: 0, cargo: 1 },
cargo: "-",
load: 0,
destination: 1,
speed: 25,
mass: 12,
state: "In_Orbit",
},
],
otherGroup: [],
incomingGroup: [],
unidentifiedGroup: [],
localFleet: [],
};
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(
() => window.__galaxyDebug?.ready === true,
);
await page.evaluate(async () => {
const debug = window.__galaxyDebug!;
await debug.loadSession();
await debug.setDeviceSessionId("phase-20-send-session");
});
void SESSION_ID;
}
async function loadSyntheticGame(page: Page): Promise<void> {
await page.goto("/lobby");
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
const file = page.getByTestId("lobby-synthetic-file");
await file.setInputFiles({
name: "phase20.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
});
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
timeout: 10_000,
});
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
}
// projectWorldToScreen returns the pixel coordinates of a world-space
// point (x, y) relative to the document, using the renderer's
// debug-surface camera snapshot. Waits for the renderer to register
// its debug providers (the in-game shell calls
// `installRendererDebugSurface` on mount, then the providers attach
// when `mountRenderer` resolves) so the spec is robust against the
// async Pixi boot.
async function projectWorldToScreen(
page: Page,
x: number,
y: number,
): Promise<{ x: number; y: number }> {
await page.waitForFunction(() => {
const dbg = window.__galaxyDebug as unknown as
| { getMapCamera(): unknown }
| undefined;
if (dbg === undefined) return false;
return dbg.getMapCamera() !== null;
});
return page.evaluate(({ wx, wy }) => {
const debug = window.__galaxyDebug as unknown as {
getMapCamera(): {
camera: { centerX: number; centerY: number; scale: number };
viewport: { widthPx: number; heightPx: number };
canvasOrigin: { x: number; y: number };
} | null;
};
const cam = debug.getMapCamera();
if (cam === null) throw new Error("camera unavailable");
const sx = cam.canvasOrigin.x + cam.viewport.widthPx / 2 +
(wx - cam.camera.centerX) * cam.camera.scale;
const sy = cam.canvasOrigin.y + cam.viewport.heightPx / 2 +
(wy - cam.camera.centerY) * cam.camera.scale;
return { x: sx, y: sy };
}, { wx: x, wy: y });
}
test("send 2 of 3 ships emits implicit Break + Send into the order draft", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 20 spec covers desktop layout; mobile inherits the same store",
);
await bootSession(page);
await loadSyntheticGame(page);
// On-planet ship groups are *not* rendered as map primitives (the
// renderer hides them to avoid crowding); the player navigates to
// them through the planet inspector's stationed-ship row, which
// pivots the SelectionStore to the ship-group variant.
const earthScreen = await projectWorldToScreen(page, 100, 100);
await page.mouse.click(earthScreen.x, earthScreen.y);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
await sidebar
.getByTestId("inspector-planet-ship-groups-select")
.first()
.click();
await expect(
sidebar.getByTestId("inspector-ship-group-class"),
).toHaveText("Frontier");
// Open Send.
await sidebar.getByTestId("inspector-ship-group-action-send").click();
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
await sendShips.fill("2");
// Pick Mars on the map.
await sidebar.getByTestId("inspector-ship-group-form-send-pick").click();
const marsScreen = await projectWorldToScreen(page, 110, 100);
await page.mouse.click(marsScreen.x, marsScreen.y);
await expect(
sidebar.getByTestId("inspector-ship-group-form-send-destination"),
).toContainText("Mars");
// Confirm.
await sidebar.getByTestId("inspector-ship-group-form-send-confirm").click();
// Verify the order tab carries both commands in submission order.
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"split group",
);
await expect(orderTool.getByTestId("order-command-label-1")).toContainText(
"send group",
);
});
+1
View File
@@ -217,5 +217,6 @@ function mockCore(opts: MockCoreOptions): Core & {
speed: () => 0,
cargoCapacity: () => 0,
carryingMass: () => 0,
blockUpgradeCost: () => 0,
};
}
@@ -18,10 +18,12 @@ export const EMPTY_SHIP_GROUPS: {
incomingShipGroups: ReportIncomingShipGroup[];
unidentifiedShipGroups: ReportUnidentifiedShipGroup[];
localFleets: ReportLocalFleet[];
otherRaces: string[];
} = {
localShipGroups: [],
otherShipGroups: [],
incomingShipGroups: [],
unidentifiedShipGroups: [],
localFleets: [],
otherRaces: [],
};
@@ -0,0 +1,264 @@
// Vitest coverage for Phase 20's ship-group action panel. Exercises
// the disabled-with-tooltip rules per action, the implicit-split
// pattern (an action targeting fewer ships than the group holds
// emits a `breakShipGroup` command before the action), and the
// happy-path commits of every variant. The dismantle confirmation
// for foreign-COL groups lives in its own file
// (`inspector-ship-group-dismantle-confirm.test.ts`); the modernize
// cost preview lives in `inspector-ship-group-modernize-cost.test.ts`.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportLocalFleet,
ReportLocalShipGroup,
ReportPlanet,
ShipClassSummary,
} from "../src/api/game-state";
import ShipGroup, {
type ShipGroupSelection,
} from "../src/lib/inspectors/ship-group.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
const PLANETS: ReportPlanet[] = [
planet({ number: 17, name: "Castle", x: 100, y: 100, kind: "local" }),
planet({ number: 99, name: "Outpost", x: 110, y: 110, kind: "other", owner: "Foreign" }),
planet({ number: 33, name: "Reach", x: 150, y: 150, kind: "uninhabited" }),
];
const SHIP_CLASS_FRONTIER: ShipClassSummary = {
name: "Frontier",
drive: 5,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
};
beforeEach(async () => {
dbName = `galaxy-ship-group-actions-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function planet(
overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number" | "name" | "x" | "y" | "kind">,
): ReportPlanet {
return {
owner: null,
size: 1000,
resources: 5,
industryStockpile: 100,
materialsStockpile: 100,
industry: 100,
population: 100,
colonists: 100,
production: null,
freeIndustry: 100,
...overrides,
};
}
function localGroup(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
count: 3,
class: "Frontier",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 1 },
cargo: "NONE",
load: 0,
destination: 17,
origin: null,
range: null,
speed: 0,
mass: 12,
state: "In_Orbit",
fleet: null,
...overrides,
};
}
function mount(
group: ReportLocalShipGroup,
options: {
otherRaces?: string[];
localFleets?: ReportLocalFleet[];
localPlayerDrive?: number;
} = {},
) {
const selection: ShipGroupSelection = { variant: "local", group };
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
]);
return render(ShipGroup, {
props: {
selection,
planets: PLANETS,
localShipClass: [SHIP_CLASS_FRONTIER],
localFleets: options.localFleets ?? [],
otherRaces: options.otherRaces ?? ["Aliens"],
mapWidth: 1000,
mapHeight: 1000,
localPlayerDrive: options.localPlayerDrive ?? 5,
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 2,
},
context,
});
}
describe("ship-group inspector — action enablement", () => {
test("non-orbit groups disable every action with the busy tooltip", () => {
const ui = mount(localGroup({ state: "In_Space" }));
for (const id of [
"inspector-ship-group-action-split",
"inspector-ship-group-action-send",
"inspector-ship-group-action-load",
"inspector-ship-group-action-unload",
"inspector-ship-group-action-modernize",
"inspector-ship-group-action-dismantle",
"inspector-ship-group-action-transfer",
"inspector-ship-group-action-join-fleet",
]) {
const button = ui.getByTestId(id);
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/ships are busy/i);
}
});
test("send is disabled when no planet is in drive range", () => {
const ui = mount(localGroup({ destination: 17 }), { localPlayerDrive: 0 });
const button = ui.getByTestId("inspector-ship-group-action-send");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/no planets are within drive range/i);
});
test("transfer is disabled when there are no other races", () => {
const ui = mount(localGroup(), { otherRaces: [] });
const button = ui.getByTestId("inspector-ship-group-action-transfer");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/no other non-extinct races/i);
});
test("unload is disabled when the group carries no cargo", () => {
const ui = mount(localGroup({ cargo: "NONE", load: 0 }));
const button = ui.getByTestId("inspector-ship-group-action-unload");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/empty/i);
});
test("unload of colonists is blocked over a foreign planet", () => {
const ui = mount(localGroup({ destination: 99, cargo: "COL", load: 1.5 }));
const button = ui.getByTestId("inspector-ship-group-action-unload");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/colonists cannot be unloaded over a foreign planet/i);
});
test("load is blocked over a foreign planet", () => {
const ui = mount(localGroup({ destination: 99 }));
const button = ui.getByTestId("inspector-ship-group-action-load");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/own or unowned planets/i);
});
});
describe("ship-group inspector — implicit split + action", () => {
test("split with K=1 of 3 emits a single breakShipGroup", async () => {
const ui = mount(localGroup({ count: 3 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-split"));
const input = ui.getByTestId("inspector-ship-group-form-split-ships") as HTMLInputElement;
await fireEvent.input(input, { target: { value: "1" } });
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-split-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("breakShipGroup");
if (cmd.kind !== "breakShipGroup") return;
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
expect(cmd.quantity).toBe(1);
});
test("dismantle on the whole group emits a single dismantleShipGroup", async () => {
const ui = mount(localGroup({ count: 2 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-dismantle-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("dismantleShipGroup");
if (cmd.kind !== "dismantleShipGroup") return;
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
});
test("dismantle on a subset emits implicit Break + Dismantle on the new group", async () => {
const ui = mount(localGroup({ count: 3 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
const input = ui.getByTestId("inspector-ship-group-form-dismantle-ships") as HTMLInputElement;
await fireEvent.input(input, { target: { value: "2" } });
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-dismantle-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(2));
const [breakCmd, action] = draft.commands;
if (breakCmd?.kind !== "breakShipGroup") throw new Error("expected break first");
if (action?.kind !== "dismantleShipGroup") throw new Error("expected dismantle second");
expect(breakCmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
expect(breakCmd.quantity).toBe(2);
expect(action.groupId).toBe(breakCmd.newGroupId);
});
test("transfer to the only available race emits a transferShipGroup", async () => {
const ui = mount(localGroup(), { otherRaces: ["Aliens"] });
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-transfer"));
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-transfer-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "transferShipGroup") throw new Error("wrong kind");
expect(cmd.acceptor).toBe("Aliens");
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
});
test("join fleet with a fresh name emits joinFleetShipGroup", async () => {
const ui = mount(localGroup());
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-join-fleet"));
const input = ui.getByTestId("inspector-ship-group-form-join-fleet-new") as HTMLInputElement;
await fireEvent.input(input, { target: { value: "Vanguard" } });
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-join-fleet-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "joinFleetShipGroup") throw new Error("wrong kind");
expect(cmd.name).toBe("Vanguard");
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
});
});
@@ -0,0 +1,201 @@
// Vitest coverage for the Phase 20 dismantle confirmation. The
// inspector requires an explicit second click ("colonists die") when
// the player tries to dismantle a colonist-laden group over a
// foreign planet — engine rule reference:
// `controller/ship_group.go.shipGroupDismantle:177-179` (over a
// foreign planet, `UnloadColonists` is not called and the cargo is
// lost).
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportLocalShipGroup,
ReportPlanet,
ShipClassSummary,
} from "../src/api/game-state";
import ShipGroup, {
type ShipGroupSelection,
} from "../src/lib/inspectors/ship-group.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
const PLANETS: ReportPlanet[] = [
{
number: 99,
name: "Outpost",
x: 100,
y: 100,
kind: "other",
owner: "Foreign",
size: 500,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 500,
population: 500,
colonists: 100,
production: "Capital",
freeIndustry: 500,
},
{
number: 17,
name: "Castle",
x: 50,
y: 50,
kind: "local",
owner: null,
size: 1000,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 1000,
population: 1000,
colonists: 100,
production: "Capital",
freeIndustry: 1000,
},
];
const SHIP_CLASS_FRONTIER: ShipClassSummary = {
name: "Frontier",
drive: 5,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
};
beforeEach(async () => {
dbName = `galaxy-ship-group-dismantle-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function group(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
count: 2,
class: "Frontier",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 1 },
cargo: "COL",
load: 1.5,
destination: 99,
origin: null,
range: null,
speed: 0,
mass: 12,
state: "In_Orbit",
fleet: null,
...overrides,
};
}
function mount(g: ReportLocalShipGroup) {
const selection: ShipGroupSelection = { variant: "local", group: g };
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
]);
return render(ShipGroup, {
props: {
selection,
planets: PLANETS,
localShipClass: [SHIP_CLASS_FRONTIER],
localFleets: [],
otherRaces: ["Aliens"],
mapWidth: 1000,
mapHeight: 1000,
localPlayerDrive: 5,
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 2,
},
context,
});
}
describe("ship-group inspector — dismantle confirmation", () => {
test("first click on dismantle of foreign-COL group shows the warning and adds nothing", async () => {
const ui = mount(group());
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.getByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeInTheDocument();
const confirm = ui.getByTestId(
"inspector-ship-group-form-dismantle-confirm",
);
expect(confirm).toHaveTextContent(/colonists die/i);
await fireEvent.click(confirm);
expect(draft.commands).toHaveLength(0);
});
test("second click on the colonists-die confirm emits dismantleShipGroup", async () => {
const ui = mount(group());
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
const confirm = ui.getByTestId(
"inspector-ship-group-form-dismantle-confirm",
);
await fireEvent.click(confirm);
await fireEvent.click(confirm);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("dismantleShipGroup");
});
test("dismantle over own planet skips the warning even with COL aboard", async () => {
const ui = mount(group({ destination: 17 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.queryByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-ship-group-form-dismantle-confirm"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
expect(draft.commands[0]!.kind).toBe("dismantleShipGroup");
});
test("dismantle over foreign planet without colonists skips the warning", async () => {
const ui = mount(group({ cargo: "NONE", load: 0 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.queryByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-ship-group-form-dismantle-confirm"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
});
});
@@ -0,0 +1,204 @@
// Vitest coverage for the Phase 20 modernize cost preview. The
// preview line in the inspector calls `core.blockUpgradeCost` once
// per ship block and multiplies the per-ship total by the number of
// targeted ships. The preview hides when `Core` is unavailable; when
// `tech === "ALL"` the targets are the player's race tech levels;
// otherwise only the picked block contributes to the cost.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportLocalShipGroup,
ReportPlanet,
ShipClassSummary,
} from "../src/api/game-state";
import ShipGroup, {
type ShipGroupSelection,
} from "../src/lib/inspectors/ship-group.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
import type { Core } from "../src/platform/core/index";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
const PLANETS: ReportPlanet[] = [
{
number: 17,
name: "Castle",
x: 100,
y: 100,
kind: "local",
owner: null,
size: 1000,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 1000,
population: 1000,
colonists: 0,
production: "Capital",
freeIndustry: 1000,
},
];
const SHIP_CLASS_CRUISER: ShipClassSummary = {
name: "Cruiser",
drive: 5,
armament: 0,
weapons: 0,
shields: 5,
cargo: 5,
};
beforeEach(async () => {
dbName = `galaxy-ship-group-modernize-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function group(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
count: 4,
class: "Cruiser",
tech: { drive: 1, weapons: 0, shields: 1, cargo: 1 },
cargo: "NONE",
load: 0,
destination: 17,
origin: null,
range: null,
speed: 0,
mass: 25,
state: "In_Orbit",
fleet: null,
...overrides,
};
}
// stubCore mirrors `pkg/calc/ship.go.BlockUpgradeCost` exactly so the
// preview line shows the same number the WASM bridge would produce.
// The other Core methods are no-ops because the modernize preview
// only consults `weaponsBlockMass` (returns null when armament is
// zero) and `blockUpgradeCost`.
function stubCore(): Core {
return {
signRequest: () => new Uint8Array(),
verifyResponse: () => true,
verifyEvent: () => true,
verifyPayloadHash: () => true,
driveEffective: ({ drive, driveTech }) => drive * driveTech,
emptyMass: () => 0,
weaponsBlockMass: ({ weapons, armament }) => {
if ((armament === 0 && weapons !== 0) || (armament !== 0 && weapons === 0)) {
return null;
}
return (armament + 1) * (weapons / 2);
},
fullMass: ({ emptyMass, carryingMass }) => emptyMass + carryingMass,
speed: () => 0,
cargoCapacity: () => 0,
carryingMass: () => 0,
blockUpgradeCost: ({ blockMass, currentTech, targetTech }) => {
if (blockMass === 0 || targetTech <= currentTech) return 0;
return (1 - currentTech / targetTech) * 10 * blockMass;
},
};
}
function mount(
g: ReportLocalShipGroup,
options: { core?: Core | null } = {},
) {
const selection: ShipGroupSelection = { variant: "local", group: g };
const holder = new CoreHolder();
if (options.core !== undefined) holder.set(options.core);
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
[CORE_CONTEXT_KEY, holder],
]);
return render(ShipGroup, {
props: {
selection,
planets: PLANETS,
localShipClass: [SHIP_CLASS_CRUISER],
localFleets: [],
otherRaces: [],
mapWidth: 1000,
mapHeight: 1000,
localPlayerDrive: 2,
localPlayerWeapons: 2,
localPlayerShields: 2,
localPlayerCargo: 2,
},
context,
});
}
describe("ship-group inspector — modernize cost preview", () => {
test("ALL upgrade preview matches the BlockUpgradeCost formula × ship count", async () => {
// drive: mass=5 current=1 target=2 → (1 - 0.5) * 10 * 5 = 25
// shields: mass=5 current=1 target=2 → 25
// cargo: mass=5 current=1 target=2 → 25
// weapons: armament=0 weapons=0 → block mass 0 → 0
// per-ship = 75; group of 4 → 300
const ui = mount(group(), { core: stubCore() });
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
expect(preview).toHaveTextContent("300");
});
test("per-block tech with custom level uses only that block", async () => {
// DRIVE only, target=2: 25 per ship × 4 = 100.
const ui = mount(group(), { core: stubCore() });
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
await fireEvent.change(
ui.getByTestId("inspector-ship-group-form-modernize-tech"),
{ target: { value: "DRIVE" } },
);
await fireEvent.input(
ui.getByTestId("inspector-ship-group-form-modernize-level"),
{ target: { value: "2" } },
);
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
expect(preview).toHaveTextContent("100");
});
test("preview is unavailable when Core is not loaded", async () => {
const ui = mount(group(), { core: null });
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
expect(preview).toHaveTextContent(/preview unavailable/i);
});
});
@@ -0,0 +1,244 @@
// Vitest coverage for the Phase 20 ship-group command shapes —
// `validateCommand` for each of the eight new variants. The
// validator is invoked through the public `OrderDraftStore.add`
// path so a regression in either layer surfaces here.
import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { OrderDraftStore } from "../src/sync/order-draft.svelte";
import type { OrderCommand } from "../src/sync/order-types";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
const GROUP_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const NEW_GROUP_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
beforeEach(async () => {
dbName = `galaxy-validate-ship-group-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
async function statusOf(cmd: OrderCommand): Promise<string> {
await draft.add(cmd);
return draft.statuses[cmd.id]!;
}
describe("validateCommand — ship-group variants", () => {
test("breakShipGroup with positive quantity is valid", async () => {
expect(
await statusOf({
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: NEW_GROUP_ID,
quantity: 2,
}),
).toBe("valid");
});
test("breakShipGroup with quantity 0 is invalid", async () => {
expect(
await statusOf({
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: NEW_GROUP_ID,
quantity: 0,
}),
).toBe("invalid");
});
test("breakShipGroup with same source and new id is invalid", async () => {
expect(
await statusOf({
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: GROUP_ID,
quantity: 1,
}),
).toBe("invalid");
});
test("sendShipGroup with positive destination is valid", async () => {
expect(
await statusOf({
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
destinationPlanetNumber: 7,
}),
).toBe("valid");
});
test("sendShipGroup to planet 0 is invalid", async () => {
expect(
await statusOf({
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
destinationPlanetNumber: 0,
}),
).toBe("invalid");
});
test("loadShipGroup with valid cargo and quantity is valid", async () => {
expect(
await statusOf({
kind: "loadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
cargo: "COL",
quantity: 1.5,
}),
).toBe("valid");
});
test("loadShipGroup with zero quantity is invalid", async () => {
expect(
await statusOf({
kind: "loadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
cargo: "COL",
quantity: 0,
}),
).toBe("invalid");
});
test("unloadShipGroup with positive quantity is valid", async () => {
expect(
await statusOf({
kind: "unloadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
quantity: 0.5,
}),
).toBe("valid");
});
test("upgradeShipGroup ALL with level 0 is valid", async () => {
expect(
await statusOf({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "ALL",
level: 0,
}),
).toBe("valid");
});
test("upgradeShipGroup ALL with non-zero level is invalid", async () => {
expect(
await statusOf({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "ALL",
level: 2,
}),
).toBe("invalid");
});
test("upgradeShipGroup DRIVE with positive level is valid", async () => {
expect(
await statusOf({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "DRIVE",
level: 1.5,
}),
).toBe("valid");
});
test("upgradeShipGroup DRIVE with level 0 is invalid", async () => {
expect(
await statusOf({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "DRIVE",
level: 0,
}),
).toBe("invalid");
});
test("dismantleShipGroup with valid uuid is valid", async () => {
expect(
await statusOf({
kind: "dismantleShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
}),
).toBe("valid");
});
test("transferShipGroup with valid acceptor name is valid", async () => {
expect(
await statusOf({
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
acceptor: "Aliens",
}),
).toBe("valid");
});
test("transferShipGroup with empty acceptor is invalid", async () => {
expect(
await statusOf({
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
acceptor: "",
}),
).toBe("invalid");
});
test("joinFleetShipGroup with valid name is valid", async () => {
expect(
await statusOf({
kind: "joinFleetShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
name: "Vanguard",
}),
).toBe("valid");
});
test("joinFleetShipGroup with empty name is invalid", async () => {
expect(
await statusOf({
kind: "joinFleetShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
name: "",
}),
).toBe("invalid");
});
});
@@ -0,0 +1,266 @@
// Vitest round-trip coverage for the eight Phase 20 ship-group
// command shapes. The encoder lives in `sync/submit.ts`; the
// decoder lives in `sync/order-load.ts`. We capture the request
// bytes the encoder produces, re-emit them inside a
// `UserGamesOrderGetResponse` envelope, and feed that to
// `fetchOrder`. The decoded command must match the original — any
// drift between encoder and decoder fails here first.
import { Builder, ByteBuffer } from "flatbuffers";
import { describe, expect, test, vi } from "vitest";
import type { GalaxyClient } from "../src/api/galaxy-client";
import { uuidToHiLo } from "../src/api/game-state";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandShipGroupBreak,
CommandShipGroupDismantle,
CommandShipGroupJoinFleet,
CommandShipGroupLoad,
CommandShipGroupSend,
CommandShipGroupTransfer,
CommandShipGroupUnload,
CommandShipGroupUpgrade,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
import { fetchOrder } from "../src/sync/order-load";
import { submitOrder } from "../src/sync/submit";
import type { OrderCommand } from "../src/sync/order-types";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
const GROUP_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
function mockClient(
executeCommand: (
messageType: string,
payload: Uint8Array,
) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>,
): GalaxyClient {
return { executeCommand } as unknown as GalaxyClient;
}
// captureRequestBytes runs submitOrder against a mock that records
// the outgoing payload, then returns those bytes (which are a valid
// `UserGamesOrder` envelope).
async function captureRequestBytes(cmds: OrderCommand[]): Promise<Uint8Array> {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_msg: string, payload: Uint8Array) => {
captured = payload;
const builder = new Builder(64);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(0));
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return { resultCode: "ok", payloadBytes: builder.asUint8Array() };
});
const result = await submitOrder(mockClient(exec), GAME_ID, cmds);
expect(result.ok).toBe(true);
expect(captured).not.toBeNull();
return captured!;
}
// wrapAsGetResponse rebuilds the captured `UserGamesOrder` inside a
// `UserGamesOrderGetResponse` envelope by walking each
// `CommandItem`, copying its identity fields, and re-packing each
// payload through `unpack().pack(builder)` — the FBS-generated
// helper that round-trips a typed table into a fresh builder.
function wrapAsGetResponse(orderBytes: Uint8Array): Uint8Array {
const order = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(orderBytes),
);
const builder = new Builder(256);
const itemOffsets: number[] = [];
for (let i = 0; i < order.commandsLength(); i++) {
const item = order.commands(i);
if (item === null) continue;
const cmdIdOffset = builder.createString(item.cmdId() ?? "");
const payloadType = item.payloadType();
const payloadOffset = packPayload(builder, item, payloadType);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, payloadType);
CommandItem.addPayload(builder, payloadOffset);
itemOffsets.push(CommandItem.endCommandItem(builder));
}
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, order.updatedAt());
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const resOffset =
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(resOffset);
return builder.asUint8Array();
}
function packPayload(
builder: Builder,
item: NonNullable<ReturnType<UserGamesOrder["commands"]>>,
payloadType: CommandPayload,
): number {
switch (payloadType) {
case CommandPayload.CommandShipGroupBreak: {
const inner = new CommandShipGroupBreak();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupSend: {
const inner = new CommandShipGroupSend();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupLoad: {
const inner = new CommandShipGroupLoad();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupUnload: {
const inner = new CommandShipGroupUnload();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupUpgrade: {
const inner = new CommandShipGroupUpgrade();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupDismantle: {
const inner = new CommandShipGroupDismantle();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupTransfer: {
const inner = new CommandShipGroupTransfer();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupJoinFleet: {
const inner = new CommandShipGroupJoinFleet();
item.payload(inner);
return inner.unpack().pack(builder);
}
default:
throw new Error(`unsupported payload type ${payloadType}`);
}
}
async function roundTrip(cmd: OrderCommand): Promise<OrderCommand> {
const requestBytes = await captureRequestBytes([cmd]);
const responseBytes = wrapAsGetResponse(requestBytes);
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: responseBytes,
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 0);
expect(result.commands).toHaveLength(1);
return result.commands[0]!;
}
describe("submit + order-load round-trip — ship-group commands", () => {
test("breakShipGroup", async () => {
const cmd: OrderCommand = {
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: "11112222-3333-4444-5555-666677778888",
quantity: 3,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("sendShipGroup", async () => {
const cmd: OrderCommand = {
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
destinationPlanetNumber: 42,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("loadShipGroup", async () => {
const cmd: OrderCommand = {
kind: "loadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
cargo: "MAT",
quantity: 12.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("unloadShipGroup", async () => {
const cmd: OrderCommand = {
kind: "unloadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
quantity: 6.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("upgradeShipGroup ALL", async () => {
const cmd: OrderCommand = {
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "ALL",
level: 0,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("upgradeShipGroup DRIVE level 1.5", async () => {
const cmd: OrderCommand = {
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "DRIVE",
level: 1.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("dismantleShipGroup", async () => {
const cmd: OrderCommand = {
kind: "dismantleShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("transferShipGroup", async () => {
const cmd: OrderCommand = {
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
acceptor: "Aliens",
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("joinFleetShipGroup", async () => {
const cmd: OrderCommand = {
kind: "joinFleetShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
name: "Vanguard",
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
});