feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s

Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 19:52:08 +02:00
parent 00159ddf7c
commit 9ae7b88b89
53 changed files with 3748 additions and 1298 deletions
@@ -1,7 +1,7 @@
// 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 designer (`active-view/designer-ship-class.svelte`)
// 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`).
@@ -33,9 +33,12 @@ import {
* translation keys for those branches and adds new keys only for
* the value-derived ones.
*/
export type ShipClassInvalidReason =
| EntityNameInvalidReason
| "duplicate_name"
/**
* 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"
@@ -45,6 +48,11 @@ export type ShipClassInvalidReason =
| "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;
@@ -60,10 +68,17 @@ export interface ShipClassDraft {
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
@@ -84,38 +99,9 @@ export function validateShipClass(
}
const trimmedName = nameResult.value;
if (!isValidDWSC(draft.drive)) {
return { ok: false, reason: "drive_value" };
}
if (!Number.isFinite(draft.armament) || draft.armament < 0) {
return { ok: false, reason: "armament_value" };
}
if (!Number.isInteger(draft.armament)) {
return { ok: false, reason: "armament_not_integer" };
}
if (!isValidDWSC(draft.weapons)) {
return { ok: false, reason: "weapons_value" };
}
if (!isValidDWSC(draft.shields)) {
return { ok: false, reason: "shields_value" };
}
if (!isValidDWSC(draft.cargo)) {
return { ok: false, reason: "cargo_value" };
}
if (
(draft.armament === 0 && draft.weapons !== 0) ||
(draft.armament !== 0 && draft.weapons === 0)
) {
return { ok: false, reason: "armament_weapons_pair" };
}
if (
draft.drive === 0 &&
draft.armament === 0 &&
draft.weapons === 0 &&
draft.shields === 0 &&
draft.cargo === 0
) {
return { ok: false, reason: "all_zero" };
const valueResult = validateShipClassValues(draft);
if (!valueResult.ok) {
return { ok: false, reason: valueResult.reason };
}
const existing = options.existingNames ?? [];
@@ -129,6 +115,51 @@ export function validateShipClass(
};
}
/**
* 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