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
+46 -2
View File
@@ -173,11 +173,41 @@ export class OrderDraftStore {
* triggers an auto-sync to keep the server in lock-step.
* Mutations made before `init` resolves are ignored — the layout
* always awaits `init` before exposing the store.
*
* `setProductionType` carries a collapse-by-`planetNumber` rule:
* a new entry supersedes any prior `setProductionType` for the
* same planet, so the draft holds at most one production choice
* per planet at any time. Other variants append unconditionally —
* `planetRename` keeps its append-only behaviour because each
* rename is a distinct user-visible action.
*/
async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return;
this.commands = [...this.commands, command];
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
const removed: string[] = [];
let nextCommands: OrderCommand[];
if (command.kind === "setProductionType") {
nextCommands = [];
for (const existing of this.commands) {
if (
existing.kind === "setProductionType" &&
existing.planetNumber === command.planetNumber
) {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else {
nextCommands = [...this.commands, command];
}
this.commands = nextCommands;
const nextStatuses = { ...this.statuses };
for (const id of removed) {
delete nextStatuses[id];
}
nextStatuses[command.id] = validateCommand(command);
this.statuses = nextStatuses;
await this.persist();
this.scheduleSync();
}
@@ -400,6 +430,20 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
switch (cmd.kind) {
case "planetRename":
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "setProductionType":
// Mirrors the engine's `subject=Production` validator
// (`game/internal/router/validator.go`): SCIENCE and SHIP
// require a non-empty entity-name-valid subject; the other
// six production types accept any subject (typically empty)
// because the engine only consults the subject for those
// two cases.
if (
cmd.productionType === "SCIENCE" ||
cmd.productionType === "SHIP"
) {
return validateEntityName(cmd.subject).ok ? "valid" : "invalid";
}
return "valid";
case "placeholder":
// Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable.
+54 -1
View File
@@ -12,11 +12,13 @@ import { uuidToHiLo } from "../api/game-state";
import { UUID } from "../proto/galaxy/fbs/common";
import {
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
PlanetProduction,
UserGamesOrderGet,
UserGamesOrderGetResponse,
} from "../proto/galaxy/fbs/order";
import type { OrderCommand } from "./order-types";
import type { OrderCommand, ProductionType } from "./order-types";
const MESSAGE_TYPE = "user.games.order.get";
@@ -135,6 +137,24 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
name: inner.name() ?? "",
};
}
case CommandPayload.CommandPlanetProduce: {
const inner = new CommandPlanetProduce();
item.payload(inner);
const productionType = productionTypeFromFBS(inner.production());
if (productionType === null) {
console.warn(
`fetchOrder: skipping CommandPlanetProduce with unknown production enum (${inner.production()})`,
);
return null;
}
return {
kind: "setProductionType",
id,
planetNumber: Number(inner.number()),
productionType,
subject: inner.subject() ?? "",
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
@@ -143,6 +163,39 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
}
}
/**
* productionTypeFromFBS reverses `productionTypeToFBS` from
* `submit.ts`. `PlanetProduction.UNKNOWN` and any out-of-band value
* yield `null` so the caller drops the entry instead of fabricating a
* synthetic kind.
*/
export function productionTypeFromFBS(
value: PlanetProduction,
): ProductionType | null {
switch (value) {
case PlanetProduction.MAT:
return "MAT";
case PlanetProduction.CAP:
return "CAP";
case PlanetProduction.DRIVE:
return "DRIVE";
case PlanetProduction.WEAPONS:
return "WEAPONS";
case PlanetProduction.SHIELDS:
return "SHIELDS";
case PlanetProduction.CARGO:
return "CARGO";
case PlanetProduction.SCIENCE:
return "SCIENCE";
case PlanetProduction.SHIP:
return "SHIP";
case PlanetProduction.UNKNOWN:
return null;
default:
return null;
}
}
function decodeError(
payload: Uint8Array,
resultCode: string,
+73 -1
View File
@@ -40,13 +40,85 @@ export interface PlanetRenameCommand {
readonly name: string;
}
/**
* ProductionType mirrors the engine `PlanetProduction` enum
* (`pkg/schema/fbs/order.fbs`) and the binding tag on
* `pkg/model/order/order.go.CommandPlanetProduce.Production`. The
* values are wire-stable: the submit encoder maps them to the FBS
* enum, the read-back decoder maps them back, and the optimistic
* overlay derives the engine's display string from the same set.
*
* `MAT` is materials production, `CAP` is industry (the engine names
* carry historical meaning — "Material" and "Capital" in the display
* mapping). `DRIVE` / `WEAPONS` / `SHIELDS` / `CARGO` are the four
* implicit per-tech research tracks (no subject required). `SCIENCE`
* is research of a custom science package authored via
* `CommandScienceCreate`; `SHIP` is build of a ship class authored
* via `CommandShipClassCreate`. Both `SCIENCE` and `SHIP` require a
* non-empty `subject` that passes `validateEntityName`; the engine
* validator (`game/internal/router/validator.go`) enforces the same.
*/
export type ProductionType =
| "MAT"
| "CAP"
| "DRIVE"
| "WEAPONS"
| "SHIELDS"
| "CARGO"
| "SCIENCE"
| "SHIP";
/**
* SetProductionTypeCommand switches a planet's production target.
* Phase 15 is the first variant to carry a collapse-by-target rule:
* the order draft store keeps at most one `setProductionType` per
* `planetNumber`, replacing any earlier entry on `add`. `subject` is
* the science or ship-class name when `productionType` is `SCIENCE`
* or `SHIP`; for the other six values it is the empty string.
*/
export interface SetProductionTypeCommand {
readonly kind: "setProductionType";
readonly id: string;
readonly planetNumber: number;
readonly productionType: ProductionType;
readonly subject: string;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
* narrowing on it enables exhaustive `switch` statements at every
* call site.
*/
export type OrderCommand = PlaceholderCommand | PlanetRenameCommand;
export type OrderCommand =
| PlaceholderCommand
| PlanetRenameCommand
| SetProductionTypeCommand;
/**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
* literals. Used by validators and by the FBS converters in
* `submit.ts` and `order-load.ts` to assert that an incoming string
* is one of the wire-stable values.
*/
export const PRODUCTION_TYPE_VALUES = [
"MAT",
"CAP",
"DRIVE",
"WEAPONS",
"SHIELDS",
"CARGO",
"SCIENCE",
"SHIP",
] as const satisfies readonly ProductionType[];
/**
* isProductionType narrows an arbitrary string to the
* `ProductionType` union.
*/
export function isProductionType(value: string): value is ProductionType {
return (PRODUCTION_TYPE_VALUES as readonly string[]).includes(value);
}
/**
* CommandStatus is the lifecycle of a single command from the moment
+44 -1
View File
@@ -27,11 +27,13 @@ import { UUID } from "../proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
PlanetProduction,
UserGamesOrder,
UserGamesOrderResponse,
} from "../proto/galaxy/fbs/order";
import type { OrderCommand } from "./order-types";
import type { OrderCommand, ProductionType } from "./order-types";
const MESSAGE_TYPE = "user.games.order";
@@ -148,6 +150,19 @@ function encodeCommandPayload(
payloadOffset: offset,
};
}
case "setProductionType": {
const subjectOffset = builder.createString(cmd.subject);
const offset = CommandPlanetProduce.createCommandPlanetProduce(
builder,
BigInt(cmd.planetNumber),
productionTypeToFBS(cmd.productionType),
subjectOffset,
);
return {
payloadType: CommandPayload.CommandPlanetProduce,
payloadOffset: offset,
};
}
case "placeholder":
throw new SubmitError(
"invalid_request",
@@ -157,6 +172,34 @@ function encodeCommandPayload(
}
}
/**
* productionTypeToFBS converts the wire-stable `ProductionType` literal
* to the FlatBuffers enum value. Mirrors `planetProductionToFBS` in
* `pkg/transcoder/order.go`. The two sides are kept in lock-step so the
* gateway can decode whatever the frontend produces without a
* translation step.
*/
export function productionTypeToFBS(value: ProductionType): PlanetProduction {
switch (value) {
case "MAT":
return PlanetProduction.MAT;
case "CAP":
return PlanetProduction.CAP;
case "DRIVE":
return PlanetProduction.DRIVE;
case "WEAPONS":
return PlanetProduction.WEAPONS;
case "SHIELDS":
return PlanetProduction.SHIELDS;
case "CARGO":
return PlanetProduction.CARGO;
case "SCIENCE":
return PlanetProduction.SCIENCE;
case "SHIP":
return PlanetProduction.SHIP;
}
}
function decodeOrderResponse(
payload: Uint8Array,
commands: OrderCommand[],