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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user