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