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