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.