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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user