Files
galaxy-game/ui/frontend/src/lib/util/ship-class-validation.ts
T
Ilia Denisov 140ee8e0ee
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Build · Site / build (pull_request) Successful in 9s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · UI / test (pull_request) Successful in 3m14s
docs(site): edit rules for clarity + cross-links; migrate off rules.txt
Editorial pass over site/ru/rules.md (on top of the verbatim port):
- moved the lore intro to the RU home page, rewritten in a modern voice;
- fixed typos, replaced the TODO/WTF cargo-tech note and the abandoned
  (---ссылка---) marker with the verified mechanic and a real cross-link,
  dropped the report TODO row;
- wove organic intra-page cross-links (#combat, #movement, #victory, ...);
- documented engine nuances verified against the code: ore auto-farming
  and the capital / "запасы промышленности" store (industry capped at
  population); cargo lost with ships destroyed in battle; and that a
  losing race's colonists at a neutral planet are NOT lost — they stay
  aboard (this corrects the audit note, verified in route.go).

Migration: delete game/rules.txt (its content now lives, authoritative,
in site/ru/rules.md) and repoint every reference to it (ui/frontend code
comments + tests, ui/docs, tools, ui/PLAN.md links). Record the
RU-authoritative rule in site/README.md and CLAUDE.md. The English
site/rules.md mirror follows in a separate stage.
2026-05-31 15:56:00 +02:00

205 lines
6.9 KiB
TypeScript

// 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<ShipClassDraft, "name">;
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<Record<keyof ShipClassValues, ShipClassValueInvalidReason>> {
const errors: Partial<
Record<keyof ShipClassValues, ShipClassValueInvalidReason>
> = {};
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;
}