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
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.
205 lines
6.9 KiB
TypeScript
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;
|
|
}
|