// TS port of `pkg/calc/validator.go.ValidateShipTypeValues` plus a // thin wrapper that runs the entity-name rules and a duplicate-name // check against the live `localShipClass` projection. The validator // is reused by the ship-class calculator (`sidebar/calculator-tab.svelte`) // for inline error messages and by `OrderDraftStore.validateCommand` // to gate auto-sync, so the local invariants match the engine's // (`game/internal/controller/ship_class.go.ShipClassCreate`). // // Engine rules (from `pkg/calc/validator.go` and `site/ru/rules.md`): // // - drive, weapons, shields, cargo: float, equal to 0 or >= 1; // - armament: integer, >= 0; // - either both `armament` and `weapons` are zero or both nonzero; // - not all five values may be zero at once; // - name must satisfy `validateEntityName` (`pkg/util/string.go`). // // 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"; /** * ShipClassInvalidReason enumerates every reason * `validateShipClass` 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. */ /** * ShipClassValueInvalidReason enumerates the value-only refusals (no * name rules). The ship-class calculator validates the five blocks * independently of the name, so it consumes this narrower union. */ export type ShipClassValueInvalidReason = | "drive_value" | "armament_value" | "armament_not_integer" | "weapons_value" | "shields_value" | "cargo_value" | "armament_weapons_pair" | "all_zero"; export type ShipClassInvalidReason = | EntityNameInvalidReason | "duplicate_name" | ShipClassValueInvalidReason; /** * ShipClassDraft is the structural shape the designer composes. The * five numeric fields carry the player's typed values verbatim; * `validateShipClass` is responsible for refusing non-finite or * out-of-range entries. */ export interface ShipClassDraft { name: string; drive: number; armament: number; weapons: number; shields: number; cargo: number; } /** ShipClassValues is the five-block subset validated by value rules. */ export type ShipClassValues = Omit; export type ShipClassValidation = | { ok: true; value: ShipClassDraft } | { ok: false; reason: ShipClassInvalidReason }; export type ShipClassValuesValidation = | { ok: true } | { ok: false; reason: ShipClassValueInvalidReason }; /** * validateShipClass mirrors `ValidateShipTypeValues` plus the * entity-name rules. `existingNames` is the optimistic projection of * already-designed ship classes (from `localShipClass` 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. */ export function validateShipClass( draft: ShipClassDraft, options: { existingNames?: readonly string[] } = {}, ): ShipClassValidation { const nameResult = validateEntityName(draft.name); if (!nameResult.ok) { return { ok: false, reason: nameResult.reason }; } const trimmedName = nameResult.value; const valueResult = validateShipClassValues(draft); if (!valueResult.ok) { return { ok: false, reason: valueResult.reason }; } const existing = options.existingNames ?? []; if (existing.some((existingName) => existingName === trimmedName)) { return { ok: false, reason: "duplicate_name" }; } return { ok: true, value: { ...draft, name: trimmedName }, }; } /** * validateShipClassValues runs only the five-block value rules from * `pkg/calc/validator.go.ValidateShipTypeValues`, independent of the * name. The ship-class calculator gates its live previews on this so a * blank or in-progress name does not suppress the math. */ export function validateShipClassValues( values: ShipClassValues, ): ShipClassValuesValidation { if (!isValidDWSC(values.drive)) { return { ok: false, reason: "drive_value" }; } if (!Number.isFinite(values.armament) || values.armament < 0) { return { ok: false, reason: "armament_value" }; } if (!Number.isInteger(values.armament)) { return { ok: false, reason: "armament_not_integer" }; } if (!isValidDWSC(values.weapons)) { return { ok: false, reason: "weapons_value" }; } if (!isValidDWSC(values.shields)) { return { ok: false, reason: "shields_value" }; } if (!isValidDWSC(values.cargo)) { return { ok: false, reason: "cargo_value" }; } if ( (values.armament === 0 && values.weapons !== 0) || (values.armament !== 0 && values.weapons === 0) ) { return { ok: false, reason: "armament_weapons_pair" }; } if ( values.drive === 0 && values.armament === 0 && values.weapons === 0 && values.shields === 0 && values.cargo === 0 ) { return { ok: false, reason: "all_zero" }; } return { ok: true }; } /** * isValidDWSC mirrors `pkg/calc/validator.go.CheckShipTypeValueDWSC`: * a Drive / Weapons / Shields / Cargo value is acceptable only when * it is exactly zero or at least one. NaN, infinity, and negative * numbers are rejected. */ function isValidDWSC(value: number): boolean { if (!Number.isFinite(value)) return false; return value === 0 || value >= 1; } /** * shipClassFieldErrors returns the invalid reason for each offending * block, independently, so the calculator can mark every bad input * (not just the first failure `validateShipClassValues` reports). The * weapons/armament pairing rule flags both fields. The all-zero rule is * a whole-design condition and is left to `validateShipClassValues`. */ export function shipClassFieldErrors( values: ShipClassValues, ): Partial> { const errors: Partial< Record > = {}; if (!isValidDWSC(values.drive)) errors.drive = "drive_value"; if (!Number.isFinite(values.armament) || values.armament < 0) { errors.armament = "armament_value"; } else if (!Number.isInteger(values.armament)) { errors.armament = "armament_not_integer"; } if (!isValidDWSC(values.weapons)) errors.weapons = "weapons_value"; if (!isValidDWSC(values.shields)) errors.shields = "shields_value"; if (!isValidDWSC(values.cargo)) errors.cargo = "cargo_value"; if ( (values.armament === 0 && values.weapons !== 0) || (values.armament !== 0 && values.weapons === 0) ) { errors.weapons ??= "armament_weapons_pair"; errors.armament ??= "armament_weapons_pair"; } return errors; }