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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user