ui/phase-15: planet inspector production controls + order-draft collapse

Adds the second end-to-end command (`setProductionType`) with a
collapse-by-`planetNumber` rule on the order draft, the segmented
production-controls component on the planet inspector, the FBS
encoder/decoder pair for `CommandPlanetProduce`, and the
`localShipClass` projection on `GameReport`. Forecast number is
deferred and tracked in the new `ui/docs/calc-bridge.md`.
This commit is contained in:
Ilia Denisov
2026-05-09 15:54:30 +02:00
parent c4f1409329
commit 915b4372dd
31 changed files with 2200 additions and 76 deletions
+101 -11
View File
@@ -15,6 +15,11 @@
// rename in the local draft swaps the planet name on the rendered
// report so the player sees their intent reflected immediately,
// without waiting for the next turn cutoff.
//
// Phase 15 extends the projection with a minimal `localShipClass`
// summary so the planet inspector's Build-Ship sub-picker has data
// to render. Phase 17 (ship-class CRUD) widens `ShipClassSummary`
// when the designer ships need the full attribute set.
import { Builder, ByteBuffer } from "flatbuffers";
@@ -24,7 +29,11 @@ import {
GameReportRequest,
Report,
} from "../proto/galaxy/fbs/report";
import type { CommandStatus, OrderCommand } from "../sync/order-types";
import type {
CommandStatus,
OrderCommand,
ProductionType,
} from "../sync/order-types";
const MESSAGE_TYPE = "user.games.report";
@@ -61,6 +70,18 @@ export interface ReportPlanet {
freeIndustry: number | null;
}
/**
* ShipClassSummary is the slim projection of `report.ShipClass` the
* planet inspector's Build-Ship sub-picker needs in Phase 15. Only
* the human-visible `name` is carried — the engine command shape
* (`CommandPlanetProduce.subject`) takes the class name, not its
* underlying tech values. Phase 17 widens this type when the ship
* designer needs the full attribute set.
*/
export interface ShipClassSummary {
name: string;
}
export interface GameReport {
turn: number;
mapWidth: number;
@@ -73,6 +94,14 @@ export interface GameReport {
* has not produced a report yet (boot state).
*/
race: string;
/**
* localShipClass enumerates the player's own designed ship classes
* by name. Empty until at least one class is created
* (`CommandShipClassCreate`, Phase 17). The Build-Ship sub-picker
* shows a localized "no ship classes" placeholder when this is
* empty.
*/
localShipClass: ShipClassSummary[];
}
export async function fetchGameReport(
@@ -189,6 +218,13 @@ function decodeReport(report: Report): GameReport {
});
}
const localShipClass: ShipClassSummary[] = [];
for (let i = 0; i < report.localShipClassLength(); i++) {
const sc = report.localShipClass(i);
if (sc === null) continue;
localShipClass.push({ name: sc.name() ?? "" });
}
return {
turn: Number(report.turn()),
mapWidth: report.width(),
@@ -196,6 +232,7 @@ function decodeReport(report: Report): GameReport {
planetCount: report.planetCount(),
planets,
race: report.race() ?? "",
localShipClass,
};
}
@@ -221,10 +258,12 @@ export function uuidToHiLo(value: string): [bigint, bigint] {
/**
* applyOrderOverlay returns a copy of `report` with every locally-
* valid or still-in-flight or applied command from `commands`
* projected on top. Phase 14 understands `planetRename` only —
* every other variant passes through. The function is pure:
* callers re-derive the overlay whenever the draft or the report
* change.
* projected on top. Phase 14 introduced the overlay for
* `planetRename`; Phase 15 extends it to `setProductionType` so the
* inspector segment / map label reflect the chosen production target
* before the engine confirms it. Other variants pass through. The
* function is pure: callers re-derive the overlay whenever the draft
* or the report change.
*
* `statuses` maps command id → status. Entries with `valid`,
* `submitting`, or `applied` participate in the overlay — together
@@ -250,18 +289,69 @@ export function applyOrderOverlay(
) {
continue;
}
if (cmd.kind !== "planetRename") continue;
const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber);
if (idx < 0) continue;
if (mutatedPlanets === null) {
mutatedPlanets = [...report.planets];
if (cmd.kind === "planetRename") {
const idx = report.planets.findIndex(
(p) => p.number === cmd.planetNumber,
);
if (idx < 0) continue;
if (mutatedPlanets === null) {
mutatedPlanets = [...report.planets];
}
mutatedPlanets[idx] = { ...mutatedPlanets[idx]!, name: cmd.name };
continue;
}
if (cmd.kind === "setProductionType") {
const idx = report.planets.findIndex(
(p) => p.number === cmd.planetNumber,
);
if (idx < 0) continue;
if (mutatedPlanets === null) {
mutatedPlanets = [...report.planets];
}
mutatedPlanets[idx] = {
...mutatedPlanets[idx]!,
production: productionDisplayFromCommand(
cmd.productionType,
cmd.subject,
),
};
continue;
}
mutatedPlanets[idx] = { ...mutatedPlanets[idx]!, name: cmd.name };
}
if (mutatedPlanets === null) return report;
return { ...report, planets: mutatedPlanets };
}
/**
* productionDisplayFromCommand mirrors the engine's
* `Cache.PlanetProductionDisplayName`
* (`game/internal/controller/planet.go`) for the optimistic overlay.
* Keeping the strings byte-equal with the next server report avoids
* a flicker when the overlay drops on the next turn cutoff.
*/
export function productionDisplayFromCommand(
productionType: ProductionType,
subject: string,
): string {
switch (productionType) {
case "MAT":
return "Material";
case "CAP":
return "Capital";
case "DRIVE":
return "Drive";
case "WEAPONS":
return "Weapons";
case "SHIELDS":
return "Shields";
case "CARGO":
return "Cargo";
case "SCIENCE":
case "SHIP":
return subject;
}
}
function decodeErrorMessage(payload: Uint8Array): { code: string; message: string } {
if (payload.length === 0) {
return { code: "internal_error", message: "empty error payload" };