ui/phase-21: sciences CRUD list, designer, and production-picker integration

Lights up the player-defined sciences feature: a table view with sort
and filter, a designer with four percent inputs and a strict
sum-equals-100 gate, and a Research-sub-row integration so the
planet production picker lists the user's sciences alongside the
four tech buttons. Phase 21 decisions are baked back into ui/PLAN.md
(no UpdateScience on the wire — write-once via createScience +
removeScience; percentages instead of fractions; sciences live under
the existing Research segment).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 21:32:37 +02:00
parent 0509f2cde2
commit 7bea22b0b5
31 changed files with 2751 additions and 71 deletions
@@ -33,6 +33,7 @@ import {
import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name";
import { validateShipClass } from "$lib/util/ship-class-validation";
import { validateScience } from "$lib/util/science-validation";
const NAMESPACE = "order-drafts";
const draftKey = (gameId: string): string => `${gameId}/draft`;
@@ -518,6 +519,27 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
// active production / ship groups. Local validation only
// guards the name shape.
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "createScience":
// Mirrors `pkg/calc/validator.go.ValidateScienceValues`
// plus the entity-name rules. The wire shape is fractions
// (sum to 1.0); the validator runs without `existingNames`
// here for the same reason ship-class create does — a
// duplicate-name check is the designer's UX responsibility,
// not the draft store's.
return validateScience({
name: cmd.name,
drive: cmd.drive * 100,
weapons: cmd.weapons * 100,
shields: cmd.shields * 100,
cargo: cmd.cargo * 100,
}).ok
? "valid"
: "invalid";
case "removeScience":
// `removeScience` carries only the name; the engine checks
// that the science exists and is not referenced by active
// production. 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
+24
View File
@@ -16,6 +16,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
CommandShipClassRemove,
CommandShipGroupBreak,
@@ -234,6 +236,28 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
name: inner.name() ?? "",
};
}
case CommandPayload.CommandScienceCreate: {
const inner = new CommandScienceCreate();
item.payload(inner);
return {
kind: "createScience",
id,
name: inner.name() ?? "",
drive: inner.drive(),
weapons: inner.weapons(),
shields: inner.shields(),
cargo: inner.cargo(),
};
}
case CommandPayload.CommandScienceRemove: {
const inner = new CommandScienceRemove();
item.payload(inner);
return {
kind: "removeScience",
id,
name: inner.name() ?? "",
};
}
case CommandPayload.CommandShipGroupBreak: {
const inner = new CommandShipGroupBreak();
item.payload(inner);
+42
View File
@@ -166,6 +166,46 @@ export interface RemoveShipClassCommand {
readonly name: string;
}
/**
* CreateScienceCommand defines a new science — a named mix of four
* tech proportions (`drive`, `weapons`, `shields`, `cargo`) that sums
* to 1.0. The TS-side mirror lives in `lib/util/science-validation.ts`;
* the designer enters the values as percentages (0..100) with a
* strict sum-equals-100 gate and converts them to fractions on
* dispatch, so wire entries always satisfy the engine's
* `pkg/calc/validator.go.ValidateScienceValues` invariant
* (every value in `[0, 1]`, sum `≈ 1.0` within float tolerance).
*
* No collapse rule applies — each create is a distinct user-visible
* action and the engine refuses duplicate names server-side
* (`game/internal/controller/science.go.scienceCreate`). Editing an
* existing science means dispatching a `removeScience` first and
* then a `createScience` with the new payload; there is no
* `UpdateScience` on the wire (Phase 21 decision).
*/
export interface CreateScienceCommand {
readonly kind: "createScience";
readonly id: string;
readonly name: string;
readonly drive: number;
readonly weapons: number;
readonly shields: number;
readonly cargo: number;
}
/**
* RemoveScienceCommand drops a defined science by name. The engine
* refuses removals when the science is referenced by an active planet
* production target (`game/internal/controller/science.go.scienceRemove`)
* — that surfaces as `cmdApplied=false` on the response and the order
* tab row reads `rejected`.
*/
export interface RemoveScienceCommand {
readonly kind: "removeScience";
readonly id: string;
readonly name: string;
}
/**
* ShipGroupCargo mirrors the engine `ShipGroupCargo` enum
* (`pkg/schema/fbs/order.fbs`). Three values: colonists, capital
@@ -383,6 +423,8 @@ export type OrderCommand =
| RemoveCargoRouteCommand
| CreateShipClassCommand
| RemoveShipClassCommand
| CreateScienceCommand
| RemoveScienceCommand
| BreakShipGroupCommand
| SendShipGroupCommand
| LoadShipGroupCommand
+28
View File
@@ -31,6 +31,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
CommandShipClassRemove,
CommandShipGroupBreak,
@@ -234,6 +236,32 @@ function encodeCommandPayload(
payloadOffset: offset,
};
}
case "createScience": {
const nameOffset = builder.createString(cmd.name);
const offset = CommandScienceCreate.createCommandScienceCreate(
builder,
nameOffset,
cmd.drive,
cmd.weapons,
cmd.shields,
cmd.cargo,
);
return {
payloadType: CommandPayload.CommandScienceCreate,
payloadOffset: offset,
};
}
case "removeScience": {
const nameOffset = builder.createString(cmd.name);
const offset = CommandScienceRemove.createCommandScienceRemove(
builder,
nameOffset,
);
return {
payloadType: CommandPayload.CommandScienceRemove,
payloadOffset: offset,
};
}
case "breakShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const newIdOffset = builder.createString(cmd.newGroupId);