feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user