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
@@ -0,0 +1,187 @@
// Validates a science draft and converts it to the wire-stable
// fraction form. Phase 21 mirrors the engine's
// `pkg/calc/validator.go.ValidateScienceValues` plus the
// `validateEntityName` rules and a UX-only duplicate-name check.
//
// The designer composes percentages (each value in `[0, 100]`, sum
// equal to `100` within float tolerance) so the user can type and
// reason about whole-number proportions; the validator converts the
// percentages to fractions (`value / 100`) on success so the
// `OrderCommand` payload always carries the canonical `[0, 1]`
// summing to `1.0` shape the FBS encoder ships on the wire.
//
// Engine rules (from `pkg/calc/validator.go` and `game/rules.txt`):
//
// - drive, weapons, shields, cargo: float in `[0, 1]`;
// - the four values sum to `1.0` (the engine accepts a small
// tolerance to absorb float rounding);
// - name must satisfy `validateEntityName`
// (`pkg/util/string.go.ValidateTypeName`).
//
// The designer's UI gate is stricter than the engine's: the four
// percent inputs use `step="0.1"` and the validator refuses anything
// outside `Math.abs(sum - 100) < SUM_EPSILON_PERCENT`. Snapping to one
// decimal at input time makes the float drift small enough that the
// epsilon below comfortably covers normal arithmetic without ever
// admitting a draft the engine would reject.
//
// The duplicate-name check is a UX-only addition — the engine raises
// `EntityDuplicateIdentifierError` for a duplicate `Create` and the
// auto-sync pipeline would surface that as a `rejected` row, but
// catching it locally keeps the Save button disabled with a clear
// hint instead of a red badge after a wire round-trip.
import {
validateEntityName,
type EntityNameInvalidReason,
} from "./entity-name";
/**
* SUM_EPSILON_PERCENT is the tolerance applied when checking that the
* four science percentages sum to exactly `100`. With one-decimal
* inputs (`step="0.1"`) the maximum cumulative float drift across four
* additions is well under `1e-6`; `1e-3` keeps the check robust to
* any float arithmetic the browser might do without ever accepting a
* sum that rounds to a value the engine would refuse.
*/
export const SUM_EPSILON_PERCENT = 1e-3;
/**
* ScienceInvalidReason enumerates every reason `validateScience` can
* refuse a draft. Name-derived reasons are the same identifiers
* `validateEntityName` returns, so the designer's `aria-describedby`
* mapping reuses the existing translation keys for those branches and
* adds new keys only for the value-derived ones.
*/
export type ScienceInvalidReason =
| EntityNameInvalidReason
| "duplicate_name"
| "drive_value"
| "weapons_value"
| "shields_value"
| "cargo_value"
| "sum_not_hundred";
/**
* ScienceDraft is the structural shape the designer composes. The
* four numeric fields carry the player-typed percentages verbatim
* (each in `[0, 100]`); `validateScience` is responsible for refusing
* non-finite or out-of-range entries and the off-by-sum case.
*/
export interface ScienceDraft {
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
}
/**
* ScienceValue is the canonical form a `createScience` order carries
* on the wire: `drive`, `weapons`, `shields`, and `cargo` are
* fractions in `[0, 1]` summing to `1.0`. Convert back to percentages
* with `fractionsToPercent`.
*/
export interface ScienceValue {
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
}
export type ScienceValidation =
| { ok: true; value: ScienceValue }
| { ok: false; reason: ScienceInvalidReason };
/**
* validateScience runs the entity-name rules, the per-percent range
* check, and the sum-equals-100 gate. `existingNames` is the
* optimistic projection of already-defined sciences (from
* `localScience` after `applyOrderOverlay`). When the trimmed name
* matches any entry, the validator returns `duplicate_name` so the
* designer's Save button stays disabled. Pass an empty array (or
* omit the option) to skip the duplicate check — useful for unit
* tests of the value-only rules, and for the order-draft store's
* post-add status recompute (which sees rolling-up duplicates as
* normal because the engine arbitrates them at submit time).
*/
export function validateScience(
draft: ScienceDraft,
options: { existingNames?: readonly string[] } = {},
): ScienceValidation {
const nameResult = validateEntityName(draft.name);
if (!nameResult.ok) {
return { ok: false, reason: nameResult.reason };
}
const trimmedName = nameResult.value;
if (!isValidPercent(draft.drive)) {
return { ok: false, reason: "drive_value" };
}
if (!isValidPercent(draft.weapons)) {
return { ok: false, reason: "weapons_value" };
}
if (!isValidPercent(draft.shields)) {
return { ok: false, reason: "shields_value" };
}
if (!isValidPercent(draft.cargo)) {
return { ok: false, reason: "cargo_value" };
}
const sum = draft.drive + draft.weapons + draft.shields + draft.cargo;
if (Math.abs(sum - 100) >= SUM_EPSILON_PERCENT) {
return { ok: false, reason: "sum_not_hundred" };
}
const existing = options.existingNames ?? [];
if (existing.some((existingName) => existingName === trimmedName)) {
return { ok: false, reason: "duplicate_name" };
}
return {
ok: true,
value: {
name: trimmedName,
drive: draft.drive / 100,
weapons: draft.weapons / 100,
shields: draft.shields / 100,
cargo: draft.cargo / 100,
},
};
}
/**
* fractionsToPercent inverts `validateScience`'s percent→fraction
* conversion: it takes a wire-stable `[0, 1]` quartet and returns a
* `[0, 100]` percentage quartet. The view-mode designer uses it to
* render an existing science back as the same percent values the
* player originally typed.
*/
export function fractionsToPercent(value: {
drive: number;
weapons: number;
shields: number;
cargo: number;
}): { drive: number; weapons: number; shields: number; cargo: number } {
return {
drive: value.drive * 100,
weapons: value.weapons * 100,
shields: value.shields * 100,
cargo: value.cargo * 100,
};
}
/**
* isValidPercent guards a single percent input. Each value must be a
* finite number in `[0, 100]`; NaN, infinity, and negative or
* over-100 entries are rejected. The designer's `step="0.1"` input
* keeps users on the one-decimal grid, but the validator does not
* round here — sub-decimal precision is harmless because the
* sum-equals-100 gate already absorbs any float drift the
* `SUM_EPSILON_PERCENT` allows.
*/
function isValidPercent(value: number): boolean {
if (!Number.isFinite(value)) return false;
return value >= 0 && value <= 100;
}