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,573 +0,0 @@
<!--
Phase 17 ship-class designer. Two modes driven by the optional
`classId` URL segment:
- **new (no classId)** — empty form with five numeric fields
plus name. Save is disabled until `validateShipClass` returns
`ok`; the localised tooltip mirrors `validateEntityName`'s
invalid-reason messages and the value-rule mirrors of
`pkg/calc/validator.go.ValidateShipTypeValues`. Save adds a
`createShipClass` to the local order draft and returns to the
table.
- **view (classId set)** — read-only render of the matching row
from the optimistic overlay. Ship classes are designed once
and cannot be modified after creation (per
`game/rules.txt`); the in-game upgrade story lives elsewhere
(`CommandShipGroupUpgrade`, Phase 19/20). The view exposes a
Delete affordance (engine-side checks ensure the class is not
referenced by active production / ship groups) and a Back
button.
Phase 18 wires `pkg/calc/` (via the `Core` WASM bridge) into the
new-mode form: an `<aside class="preview">` block recomputes mass,
full-load mass, max speed, range at full load, and cargo capacity
on every form change, using the local player's tech levels off the
rendered report. The preview hides itself until the form passes
validation, so it never displays half-cooked numbers.
-->
<script lang="ts">
import { getContext, tick } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { ShipClassSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import {
validateShipClass,
type ShipClassInvalidReason,
} from "$lib/util/ship-class-validation";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const gameId = $derived(page.params.id ?? "");
const classId = $derived(page.params.classId ?? "");
const isViewMode = $derived(classId !== "");
const localShipClass = $derived<ShipClassSummary[]>(
rendered?.report?.localShipClass ?? [],
);
const existingNames = $derived(localShipClass.map((cls) => cls.name));
const viewing = $derived(
isViewMode
? localShipClass.find((cls) => cls.name === classId) ?? null
: null,
);
let name = $state("");
let drive = $state(0);
let armament = $state(0);
let weapons = $state(0);
let shields = $state(0);
let cargo = $state(0);
let nameInputEl: HTMLInputElement | null = $state(null);
const invalidReasonKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
empty: "game.designer.ship_class.invalid.empty",
too_long: "game.designer.ship_class.invalid.too_long",
starts_with_special: "game.designer.ship_class.invalid.starts_with_special",
ends_with_special: "game.designer.ship_class.invalid.ends_with_special",
consecutive_specials:
"game.designer.ship_class.invalid.consecutive_specials",
whitespace: "game.designer.ship_class.invalid.whitespace",
disallowed_character:
"game.designer.ship_class.invalid.disallowed_character",
duplicate_name: "game.designer.ship_class.invalid.duplicate_name",
drive_value: "game.designer.ship_class.invalid.drive_value",
armament_value: "game.designer.ship_class.invalid.armament_value",
armament_not_integer:
"game.designer.ship_class.invalid.armament_not_integer",
weapons_value: "game.designer.ship_class.invalid.weapons_value",
shields_value: "game.designer.ship_class.invalid.shields_value",
cargo_value: "game.designer.ship_class.invalid.cargo_value",
armament_weapons_pair:
"game.designer.ship_class.invalid.armament_weapons_pair",
all_zero: "game.designer.ship_class.invalid.all_zero",
};
const validation = $derived(
validateShipClass(
{ name, drive, armament, weapons, shields, cargo },
{ existingNames },
),
);
const invalidMessage = $derived(
validation.ok ? "" : i18n.t(invalidReasonKeyMap[validation.reason]),
);
const canSave = $derived(validation.ok && draft !== undefined);
const driveTech = $derived(rendered?.report?.localPlayerDrive ?? 0);
const cargoTech = $derived(rendered?.report?.localPlayerCargo ?? 0);
interface PreviewValues {
mass: number;
fullLoadMass: number;
maxSpeed: number;
rangeAtFull: number;
cargoCapacity: number;
}
const preview = $derived.by<PreviewValues | null>(() => {
const core = coreHandle?.core;
if (core === undefined || core === null) return null;
if (!validation.ok) return null;
const v = validation.value;
const mass = core.emptyMass({
drive: v.drive,
weapons: v.weapons,
armament: v.armament,
shields: v.shields,
cargo: v.cargo,
});
if (mass === null) return null;
const cargoCapacity = core.cargoCapacity({
cargo: v.cargo,
cargoTech,
});
const carryAtFull =
cargoTech > 0
? core.carryingMass({ load: cargoCapacity, cargoTech })
: 0;
const fullLoadMass = core.fullMass({
emptyMass: mass,
carryingMass: carryAtFull,
});
const driveEffective = core.driveEffective({
drive: v.drive,
driveTech,
});
const maxSpeed = core.speed({ driveEffective, fullMass: mass });
const rangeAtFull = core.speed({
driveEffective,
fullMass: fullLoadMass,
});
return { mass, fullLoadMass, maxSpeed, rangeAtFull, cargoCapacity };
});
$effect(() => {
if (!isViewMode) {
void tick().then(() => nameInputEl?.focus());
}
});
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function backToTable(): void {
void goto(`/games/${gameId}/table/ship-classes`);
}
async function save(): Promise<void> {
if (!validation.ok || draft === undefined) return;
await draft.add({
kind: "createShipClass",
id: crypto.randomUUID(),
name: validation.value.name,
drive: validation.value.drive,
armament: validation.value.armament,
weapons: validation.value.weapons,
shields: validation.value.shields,
cargo: validation.value.cargo,
});
backToTable();
}
async function deleteThis(): Promise<void> {
if (viewing === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: viewing.name,
});
backToTable();
}
</script>
<section
class="active-view"
data-testid="active-view-designer-ship-class"
data-mode={isViewMode ? "view" : "new"}
>
{#if isViewMode}
{#if viewing === null}
<h2>{i18n.t("game.view.designer.ship_class")}</h2>
<p class="not-found" data-testid="designer-ship-class-not-found">
{i18n.t("game.designer.ship_class.not_found", { name: classId })}
</p>
<div class="actions">
<button
type="button"
data-testid="designer-ship-class-back"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.back")}
</button>
</div>
{:else}
<h2 data-testid="designer-ship-class-title">
{i18n.t("game.designer.ship_class.title.view", { name: viewing.name })}
</h2>
<p class="notice" data-testid="designer-ship-class-notice">
{i18n.t("game.designer.ship_class.read_only_notice")}
</p>
<dl class="fields">
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.name")}</dt>
<dd data-testid="designer-ship-class-view-name">{viewing.name}</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.drive")}</dt>
<dd data-testid="designer-ship-class-view-drive">
{formatNumber(viewing.drive)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.armament")}</dt>
<dd data-testid="designer-ship-class-view-armament">
{viewing.armament}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.weapons")}</dt>
<dd data-testid="designer-ship-class-view-weapons">
{formatNumber(viewing.weapons)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.shields")}</dt>
<dd data-testid="designer-ship-class-view-shields">
{formatNumber(viewing.shields)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.cargo")}</dt>
<dd data-testid="designer-ship-class-view-cargo">
{formatNumber(viewing.cargo)}
</dd>
</div>
</dl>
<div class="actions">
<button
type="button"
class="back"
data-testid="designer-ship-class-back"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.back")}
</button>
<button
type="button"
class="delete"
data-testid="designer-ship-class-delete"
disabled={draft === undefined}
onclick={() => void deleteThis()}
>
{i18n.t("game.designer.ship_class.action.delete")}
</button>
</div>
{/if}
{:else}
<h2 data-testid="designer-ship-class-title">
{i18n.t("game.designer.ship_class.title.new")}
</h2>
<p class="hint" data-testid="designer-ship-class-hint">
{i18n.t("game.designer.ship_class.hint.values")}
</p>
<form
class="form"
data-testid="designer-ship-class-form"
onsubmit={(event) => {
event.preventDefault();
void save();
}}
>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.name")}</span>
<input
type="text"
bind:this={nameInputEl}
bind:value={name}
data-testid="designer-ship-class-input-name"
maxlength="30"
aria-invalid={validation.ok ? "false" : "true"}
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.drive")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={drive}
data-testid="designer-ship-class-input-drive"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.armament")}</span>
<input
type="number"
step="1"
min="0"
bind:value={armament}
data-testid="designer-ship-class-input-armament"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.weapons")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={weapons}
data-testid="designer-ship-class-input-weapons"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.shields")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={shields}
data-testid="designer-ship-class-input-shields"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.cargo")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={cargo}
data-testid="designer-ship-class-input-cargo"
/>
</label>
{#if !validation.ok}
<p class="error" data-testid="designer-ship-class-error">
{invalidMessage}
</p>
{/if}
{#if preview !== null}
<aside
class="preview"
data-testid="designer-ship-class-preview"
>
<h3>{i18n.t("game.designer.ship_class.preview.title")}</h3>
<dl>
<div class="row">
<dt>{i18n.t("game.designer.ship_class.preview.mass")}</dt>
<dd data-testid="designer-ship-class-preview-mass">
{formatNumber(preview.mass)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.full_load_mass")}
</dt>
<dd data-testid="designer-ship-class-preview-full-load-mass">
{formatNumber(preview.fullLoadMass)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.max_speed")}
</dt>
<dd data-testid="designer-ship-class-preview-max-speed">
{formatNumber(preview.maxSpeed)}
</dd>
</div>
<div class="row">
<dt>{i18n.t("game.designer.ship_class.preview.range")}</dt>
<dd data-testid="designer-ship-class-preview-range">
{formatNumber(preview.rangeAtFull)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.cargo_capacity")}
</dt>
<dd data-testid="designer-ship-class-preview-cargo-capacity">
{formatNumber(preview.cargoCapacity)}
</dd>
</div>
</dl>
</aside>
{/if}
<div class="actions">
<button
type="button"
class="cancel"
data-testid="designer-ship-class-cancel"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.cancel")}
</button>
<button
type="submit"
class="save"
data-testid="designer-ship-class-save"
disabled={!canSave}
title={canSave ? "" : invalidMessage}
>
{i18n.t("game.designer.ship_class.action.save")}
</button>
</div>
</form>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.active-view h2 {
margin: 0;
font-size: 1.1rem;
}
.notice,
.hint,
.not-found {
margin: 0;
color: #888;
font-size: 0.85rem;
}
.form {
display: flex;
flex-direction: column;
gap: 0.55rem;
max-width: 30rem;
}
.row {
display: grid;
grid-template-columns: 8rem 1fr;
align-items: center;
gap: 0.6rem;
}
.row span {
color: #aab;
font-size: 0.85rem;
}
.row input {
font: inherit;
padding: 0.3rem 0.5rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.row input[aria-invalid="true"] {
border-color: #d97a7a;
}
.error {
margin: 0;
font-size: 0.8rem;
color: #d97a7a;
}
.preview {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem 0.85rem;
background: #0a0e1a;
border: 1px solid #2a3150;
border-radius: 4px;
max-width: 30rem;
}
.preview h3 {
margin: 0;
font-size: 0.85rem;
color: #aab;
font-weight: 500;
}
.preview dl {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.2rem;
column-gap: 0.75rem;
}
.preview .row {
display: contents;
}
.preview dt {
color: #aab;
font-size: 0.85rem;
}
.preview dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
text-align: right;
}
.fields {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.75rem;
max-width: 30rem;
}
.field {
display: contents;
}
.field dt {
color: #aab;
font-size: 0.85rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.actions button {
font: inherit;
font-size: 0.9rem;
padding: 0.3rem 0.7rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.actions button:not(:disabled):hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.actions .delete {
color: #d97a7a;
}
.actions .delete:not(:disabled):hover {
border-color: #d97a7a;
color: #f0a0a0;
}
</style>
+31 -1
View File
@@ -31,6 +31,8 @@ preference the store already manages.
} from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { buildPendingSendLines } from "../../map/pending-send-routes";
import { computeReachCircles } from "../../map/reach-circles";
import { reachStore } from "$lib/calculator/reach.svelte";
import {
reportToWorld,
type HitTarget,
@@ -196,6 +198,11 @@ preference the store already manages.
void toggles.bombingMarkers;
void toggles.visibleHyperspace;
// Subscribe to the calculator's published reach so the rings
// redraw as the design or the selected planet changes.
void reachStore.origin;
void reachStore.speedPerTurn;
// Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the
// cascade-filtering happens here so the extras list shrinks
@@ -219,8 +226,14 @@ preference the store already manages.
// the visible set reliably triggers a push.
const draftCommands = orderDraft?.commands ?? [];
const draftStatuses = orderDraft?.statuses ?? {};
const reachOrigin = reachStore.origin;
const reachFingerprint =
reachOrigin === null
? ""
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
const extrasFingerprint =
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
`reach=${reachFingerprint}|` +
computeRoutesFingerprint(report.routes) +
"|" +
computePendingSendFingerprint(draftCommands, draftStatuses);
@@ -256,6 +269,7 @@ preference the store already manages.
draftStatuses,
toggles,
hiddenPlanetNumbers,
mode,
),
);
});
@@ -289,6 +303,7 @@ preference the store already manages.
draftStatuses: Readonly<Record<string, string>>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
mode: "torus" | "no-wrap",
): import("../../map/world").Primitive[] {
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
const cargo = toggles.cargoRoutes
@@ -300,7 +315,21 @@ preference the store already manages.
draftStatuses,
skip ? { skipPlanets: skip } : undefined,
);
return [...cargo, ...pending];
// Reach circles published by the ship-class calculator. Empty
// when no own planet is selected or the design is invalid, so
// this is a no-op for the rest of the map.
const reachOrigin = reachStore.origin;
const reach =
reachOrigin !== null && reachStore.speedPerTurn > 0
? computeReachCircles(
reachOrigin,
reachStore.speedPerTurn,
report.mapWidth,
report.mapHeight,
mode,
)
: [];
return [...cargo, ...pending, ...reach];
}
function applyVisibilityState(
@@ -342,6 +371,7 @@ preference the store already manages.
draftStatuses,
toggles,
hiddenPlanetNumbers,
mode,
),
);
lastExtrasFingerprint = extrasFingerprint;
@@ -14,8 +14,6 @@ data fetching is performed here — the layout is responsible.
-->
<script lang="ts">
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { ShipClassSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -27,6 +25,7 @@ data fetching is performed here — the layout is responsible.
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
type SortColumn =
| "name"
@@ -62,7 +61,6 @@ data fetching is performed here — the layout is responsible.
ORDER_DRAFT_CONTEXT_KEY,
);
const gameId = $derived(page.params.id ?? "");
let sortColumn: SortColumn = $state("name");
let sortDirection: SortDirection = $state("asc");
@@ -111,14 +109,12 @@ data fetching is performed here — the layout is responsible.
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function openDesigner(name: string): void {
void goto(
`/games/${gameId}/designer/ship-class/${encodeURIComponent(name)}`,
);
function openInCalculator(name: string): void {
calculatorLoadRequest.request(name);
}
function newShipClass(): void {
void goto(`/games/${gameId}/designer/ship-class`);
calculatorLoadRequest.request(null);
}
async function deleteShipClass(name: string): Promise<void> {
@@ -194,7 +190,7 @@ data fetching is performed here — the layout is responsible.
<tr
data-testid="ship-classes-row"
data-name={cls.name}
ondblclick={() => openDesigner(cls.name)}
ondblclick={() => openInCalculator(cls.name)}
>
<td data-testid="ship-classes-cell-name">{cls.name}</td>
<td data-testid="ship-classes-cell-drive">{formatNumber(cls.drive)}</td>
@@ -0,0 +1,370 @@
// Pure orchestration for the ship-class calculator. The calculator
// renders three areas — ship design, derived results, planet build — and
// supports single-target "goal-seek": the player pins one derived result
// and the model back-solves the single input it claims. All numeric math
// lives in `pkg/calc` (reached through `Core`); this module only decides
// which `Core` call to make, in what order, and how to fold the result
// back into the field set. Keeping it a pure function of
// `(CalculatorInput, Core)` makes the goal-seek logic unit-testable
// without booting WASM or mounting a component.
import type { Core } from "../../platform/core/index";
import {
validateShipClassValues,
type ShipClassValueInvalidReason,
} from "../util/ship-class-validation";
/** LockableOutputId names every derived result the player may pin. */
export type LockableOutputId =
| "emptyMass"
| "loadedMass"
| "speedEmpty"
| "speedLoaded"
| "attack"
| "defense";
/** ClaimedInput names every input a locked result can back-solve. */
export type ClaimedInput = "drive" | "weapons" | "shields" | "cargo" | "load";
/**
* CLAIM_MAP fixes which single input each lockable result back-solves.
* The pairing is the natural lever for each result: attack rides on the
* weapons block, defence on shields, both speeds on the drive block,
* empty mass on the cargo block (the free filler), and loaded mass on the
* cargo load.
*/
export const CLAIM_MAP: Record<LockableOutputId, ClaimedInput> = {
emptyMass: "cargo",
loadedMass: "load",
speedEmpty: "drive",
speedLoaded: "drive",
attack: "weapons",
defense: "shields",
};
export type LoadMode = "empty" | "full" | "custom";
export interface DesignBlocks {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
export interface CalculatorInput {
blocks: DesignBlocks;
// Effective tech levels (the caller resolves default vs. override).
driveTech: number;
weaponsTech: number;
shieldsTech: number;
cargoTech: number;
loadMode: LoadMode;
customLoad: number;
// The single pinned result, or null when nothing is locked.
lock: { output: LockableOutputId; value: number } | null;
}
export interface CalculatorOutputs {
emptyMass: number;
loadedMass: number;
speedEmpty: number;
speedLoaded: number;
attack: number;
defense: number;
bombing: number;
}
export interface CalculatorResult {
/** Blocks after goal-seek may have overwritten the claimed one. */
blocks: DesignBlocks;
/** Which input the active lock drove, or null. */
computedInput: ClaimedInput | null;
/** False when the lock's target cannot be reached. */
lockFeasible: boolean;
/** Whether the resolved blocks pass the engine value rules. */
valuesValid: boolean;
valueReason: ShipClassValueInvalidReason | null;
/** Resolved cargo load in cargo units. */
load: number;
cargoCapacity: number;
/** Derived results, or null when invalid / no Core. */
outputs: CalculatorOutputs | null;
}
function resolveLoad(
mode: LoadMode,
customLoad: number,
cargo: number,
cargoTech: number,
core: Core,
): number {
if (mode === "empty") return 0;
if (mode === "custom") return customLoad > 0 ? customLoad : 0;
return core.cargoCapacity({ cargo, cargoTech });
}
// solveClaimedBlock back-solves the block claimed by a locked result
// (everything except a `load` claim, which is resolved with the cargo
// load). Returns null when the target is unreachable or the design's
// weapons/armament pairing is invalid.
function solveClaimedBlock(
lock: { output: LockableOutputId; value: number },
raw: DesignBlocks,
input: CalculatorInput,
prelimLoad: number,
core: Core,
): number | null {
switch (lock.output) {
case "attack":
return core.weaponsForAttack({
targetAttack: lock.value,
weaponsTech: input.weaponsTech,
});
case "defense": {
const restExclShields = core.emptyMass({ ...raw, shields: 0 });
if (restExclShields === null) return null;
const carrying = core.carryingMass({
load: prelimLoad,
cargoTech: input.cargoTech,
});
return core.shieldsForDefence({
targetDefence: lock.value,
shieldsTech: input.shieldsTech,
restMass: restExclShields + carrying,
});
}
case "speedEmpty": {
const restExclDrive = core.emptyMass({ ...raw, drive: 0 });
if (restExclDrive === null) return null;
return core.driveForSpeed({
targetSpeed: lock.value,
driveTech: input.driveTech,
restMass: restExclDrive,
});
}
case "speedLoaded": {
const restExclDrive = core.emptyMass({ ...raw, drive: 0 });
if (restExclDrive === null) return null;
const carrying = core.carryingMass({
load: prelimLoad,
cargoTech: input.cargoTech,
});
return core.driveForSpeed({
targetSpeed: lock.value,
driveTech: input.driveTech,
restMass: restExclDrive + carrying,
});
}
case "emptyMass": {
const restExclCargo = core.emptyMass({ ...raw, cargo: 0 });
if (restExclCargo === null) return null;
return core.cargoForEmptyMass({
targetEmptyMass: lock.value,
restMass: restExclCargo,
});
}
case "loadedMass":
// Claims the cargo load, resolved alongside the load below.
return null;
}
}
/**
* computeCalculator resolves the full calculator state for one input
* snapshot: it applies the active goal-seek lock (if any), resolves the
* cargo load, validates the blocks, and computes every derived result.
* `outputs` is null when no `Core` is available or the blocks are
* invalid, mirroring the Phase 18 designer rule of hiding the preview
* until the design is sound.
*/
export function computeCalculator(
input: CalculatorInput,
core: Core | null,
): CalculatorResult {
const raw = input.blocks;
if (core === null) {
return {
blocks: raw,
computedInput: null,
lockFeasible: true,
valuesValid: false,
valueReason: null,
load: 0,
cargoCapacity: 0,
outputs: null,
};
}
const blocks: DesignBlocks = { ...raw };
let computedInput: ClaimedInput | null = null;
let lockFeasible = true;
// Preliminary load from the raw cargo, used by solvers that need the
// carrying mass (speedLoaded, defence). It matches the final load for
// every claim except `emptyMass` (which solves cargo without load) and
// `loadedMass` (which solves the load itself).
const prelimLoad = resolveLoad(
input.loadMode,
input.customLoad,
raw.cargo,
input.cargoTech,
core,
);
if (input.lock !== null) {
const claimed = CLAIM_MAP[input.lock.output];
if (claimed !== "load") {
const solved = solveClaimedBlock(
input.lock,
raw,
input,
prelimLoad,
core,
);
if (solved === null) {
lockFeasible = false;
} else {
blocks[claimed] = solved;
computedInput = claimed;
}
}
}
let load: number;
if (input.lock !== null && CLAIM_MAP[input.lock.output] === "load") {
const emptyMass = core.emptyMass(blocks);
const solvedLoad =
emptyMass === null
? null
: core.loadForFullMass({
targetFullMass: input.lock.value,
emptyMass,
cargoTech: input.cargoTech,
});
if (solvedLoad === null) {
lockFeasible = false;
load = resolveLoad(
input.loadMode,
input.customLoad,
blocks.cargo,
input.cargoTech,
core,
);
} else {
load = solvedLoad;
computedInput = "load";
}
} else {
load = resolveLoad(
input.loadMode,
input.customLoad,
blocks.cargo,
input.cargoTech,
core,
);
}
const valuesValidation = validateShipClassValues(blocks);
const valuesValid = valuesValidation.ok;
const valueReason = valuesValidation.ok ? null : valuesValidation.reason;
const cargoCapacity = core.cargoCapacity({
cargo: blocks.cargo,
cargoTech: input.cargoTech,
});
let outputs: CalculatorOutputs | null = null;
if (valuesValid) {
const emptyMass = core.emptyMass(blocks);
if (emptyMass !== null) {
const carrying = core.carryingMass({ load, cargoTech: input.cargoTech });
const loadedMass = core.fullMass({ emptyMass, carryingMass: carrying });
const driveEffective = core.driveEffective({
drive: blocks.drive,
driveTech: input.driveTech,
});
outputs = {
emptyMass,
loadedMass,
speedEmpty: core.speed({ driveEffective, fullMass: emptyMass }),
speedLoaded: core.speed({ driveEffective, fullMass: loadedMass }),
attack: core.effectiveAttack({
weapons: blocks.weapons,
weaponsTech: input.weaponsTech,
}),
defense: core.effectiveDefence({
shields: blocks.shields,
shieldsTech: input.shieldsTech,
fullMass: loadedMass,
}),
bombing: core.bombingPower({
weapons: blocks.weapons,
weaponsTech: input.weaponsTech,
armament: blocks.armament,
number: 1,
}),
};
}
}
return {
blocks,
computedInput,
lockFeasible,
valuesValid,
valueReason,
load,
cargoCapacity,
outputs,
};
}
export interface PlanetBuildInput {
/** The designed ship's empty mass. */
shipMass: number;
/** Free industrial potential (the "L" parameter, FreeIndustry). */
freeIndustry: number;
/** Material stockpile (resolved: planet value or the player override). */
material: number;
/** Planet resources rating. */
resources: number;
}
export interface PlanetBuildResult {
/** Whole ships plus fractional progress completable this turn. */
shipsPerTurn: number;
wholeShips: number;
progress: number;
/** Turns to finish one ship, or null when none can be produced. */
turnsPerShip: number | null;
}
/**
* computePlanetBuild folds one turn of ship production into the headline
* "ships per turn" and "turns per ship" the planet area shows. It assumes
* the planet keeps building this ship at the current (or overridden) MAT;
* the realistic multi-turn forecast with population growth and CAP/COL
* supply lands in Phase 34. Returns null without a `Core`.
*/
export function computePlanetBuild(
input: PlanetBuildInput,
core: Core | null,
): PlanetBuildResult | null {
if (core === null) return null;
if (input.shipMass <= 0 || input.freeIndustry <= 0) {
return { shipsPerTurn: 0, wholeShips: 0, progress: 0, turnsPerShip: null };
}
const r = core.produceShipsInTurn({
productionAvailable: input.freeIndustry,
material: input.material,
resources: input.resources,
shipMass: input.shipMass,
});
const shipsPerTurn = r.ships + r.progress;
return {
shipsPerTurn,
wholeShips: r.ships,
progress: r.progress,
turnsPerShip: shipsPerTurn > 0 ? 1 / shipsPerTurn : null,
};
}
@@ -0,0 +1,23 @@
// Shared signal that asks the sidebar calculator to open and load a ship
// class. The ship-classes table (row activation, "new" button) and the
// mobile bottom-tabs entry publish a request here; the in-game layout
// watches it to flip the sidebar to the calculator tab, and the
// calculator watches it to load the requested class. A module singleton
// keeps these siblings decoupled, mirroring `reach.svelte`.
//
// `token` increments on every request so a repeat request for the same
// class still re-triggers the watchers; each watcher records the last
// token it handled to act exactly once per request.
class CalculatorLoadRequest {
/** The class name to load, or null to start a fresh design. */
name: string | null = $state(null);
token = $state(0);
request(name: string | null): void {
this.name = name;
this.token += 1;
}
}
export const calculatorLoadRequest = new CalculatorLoadRequest();
@@ -0,0 +1,24 @@
// Shared bridge between the ship-class calculator (sidebar) and the map
// view: the calculator publishes the selected planet's origin and the
// current design's loaded speed here, and the map reads it to draw reach
// circles. A module singleton keeps the two siblings decoupled — neither
// imports the other — and survives sidebar tab switches. The store is
// cleared whenever the calculator has no valid design or no selected
// planet, which makes the map drop the rings.
class ReachStore {
origin: { x: number; y: number } | null = $state(null);
speedPerTurn = $state(0);
set(origin: { x: number; y: number }, speedPerTurn: number): void {
this.origin = origin;
this.speedPerTurn = speedPerTurn;
}
clear(): void {
this.origin = null;
this.speedPerTurn = 0;
}
}
export const reachStore = new ReachStore();
@@ -0,0 +1,184 @@
<!--
Reusable "Ship Class design area": the five design blocks (drive,
armament, weapons, shields, cargo) plus the four tech levels they are
built with. Each tech defaults to the player's current level and shows a
lock icon once overridden; clicking the lock resets it. A block claimed
by an active goal-seek lock renders read-only with its own lock marker.
The component is presentational — the parent owns the state and the
calculator math — so the ship-group upgrade flow can reuse it later.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import type { ClaimedInput } from "./calc-model";
export interface DesignBlocksState {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
export interface TechState {
drive: number;
weapons: number;
shields: number;
cargo: number;
}
export type TechKey = keyof TechState;
type Props = {
blocks: DesignBlocksState;
// Blocks after goal-seek: the claimed block carries its solved
// value, which is what the read-only computed cell displays.
resolved: DesignBlocksState;
techs: TechState;
techOverridden: Record<TechKey, boolean>;
computedInput?: ClaimedInput | null;
blocksReadonly?: boolean;
onTechInput: (key: TechKey) => void;
onResetTech: (key: TechKey) => void;
};
let {
blocks = $bindable(),
resolved,
techs = $bindable(),
techOverridden,
computedInput = null,
blocksReadonly = false,
onTechInput,
onResetTech,
}: Props = $props();
const BLOCK_ROWS: {
key: keyof DesignBlocksState;
label: () => string;
step: string;
tech: TechKey | null;
}[] = [
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
];
</script>
<div class="design" data-testid="calculator-design-area">
<div class="cols">
<span></span>
<span class="col-head">{i18n.t("game.calculator.col.ship")}</span>
<span class="col-head">{i18n.t("game.calculator.col.tech")}</span>
</div>
{#each BLOCK_ROWS as row (row.key)}
{@const isComputed = computedInput === row.key}
<div class="row">
<span class="label">{row.label()}</span>
{#if isComputed}
<input
class="ship"
type="number"
step={row.step}
readonly
value={resolved[row.key]}
data-computed="true"
data-testid={`calculator-block-${row.key}`}
title={i18n.t("game.calculator.lock.reset")}
/>
{:else}
<input
class="ship"
type="number"
step={row.step}
min="0"
bind:value={blocks[row.key]}
readonly={blocksReadonly}
data-testid={`calculator-block-${row.key}`}
/>
{/if}
{#if row.tech !== null}
{@const techKey = row.tech}
<span class="tech-cell">
<input
class="tech"
type="number"
step="0.001"
min="0"
bind:value={techs[techKey]}
oninput={() => onTechInput(techKey)}
data-testid={`calculator-tech-${techKey}`}
/>
{#if techOverridden[techKey]}
<button
type="button"
class="lock"
title={i18n.t("game.calculator.tech.reset")}
aria-label={i18n.t("game.calculator.tech.reset")}
data-testid={`calculator-tech-reset-${techKey}`}
onclick={() => onResetTech(techKey)}
>
🔒
</button>
{/if}
</span>
{:else}
<span></span>
{/if}
</div>
{/each}
</div>
<style>
.design {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.cols,
.row {
display: grid;
grid-template-columns: 4.5rem 1fr 1fr;
align-items: center;
gap: 0.35rem;
}
.col-head {
color: #8890b0;
font-size: 0.7rem;
text-align: center;
text-transform: lowercase;
}
.label {
color: #aab;
font-size: 0.8rem;
}
input {
font: inherit;
font-size: 0.8rem;
width: 100%;
min-width: 0;
padding: 0.2rem 0.35rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
font-variant-numeric: tabular-nums;
}
input[data-computed="true"],
input[readonly] {
color: #9fb0ff;
background: #11162a;
}
.tech-cell {
display: flex;
align-items: center;
gap: 0.2rem;
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
}
</style>
@@ -135,14 +135,6 @@ polishes microcopy.
</span>
{/if}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-designer-ship-class"
onclick={() => go(`/games/${gameId}/designer/ship-class`)}
>
{i18n.t("game.view.designer.ship_class")}
</button>
<button
type="button"
role="menuitem"
+59
View File
@@ -337,6 +337,65 @@ const en = {
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
"game.designer.ship_class.preview.unavailable": "—",
"game.calculator.title": "ship class calculator",
"game.calculator.mode.ship": "calculator",
"game.calculator.mode.modernization": "modernization",
"game.calculator.name.placeholder": "new class name",
"game.calculator.name.existing": "your ship classes",
"game.calculator.action.create": "create",
"game.calculator.action.delete": "delete",
"game.calculator.col.ship": "ship",
"game.calculator.col.tech": "tech",
"game.calculator.field.drive": "drive",
"game.calculator.field.armament": "armament",
"game.calculator.field.weapons": "weapons",
"game.calculator.field.shields": "shields",
"game.calculator.field.cargo": "cargo",
"game.calculator.load.label": "load",
"game.calculator.load.empty": "empty",
"game.calculator.load.full": "full",
"game.calculator.load.custom": "custom",
"game.calculator.col.empty": "empty",
"game.calculator.col.loaded": "loaded",
"game.calculator.out.mass": "mass",
"game.calculator.out.speed": "speed",
"game.calculator.out.attack": "attack",
"game.calculator.out.defense": "defense",
"game.calculator.out.bombing": "bombing",
"game.calculator.out.cargo_capacity": "cargo capacity",
"game.calculator.planet.title": "planet",
"game.calculator.planet.none": "select one of your planets on the map",
"game.calculator.planet.label": "planet {name} (#{number})",
"game.calculator.planet.mat": "MAT",
"game.calculator.planet.ships_per_turn": "ships / turn",
"game.calculator.planet.turns_per_ship": "turns / ship",
"game.calculator.lock.reset": "locked — click to release to the computed value",
"game.calculator.lock.infeasible": "this target cannot be reached with the current design",
"game.calculator.lock.max": "release the locked result first — one result at a time",
"game.calculator.tech.reset": "overridden — click to reset to your current tech",
"game.calculator.mat.reset": "overridden — click to reset to the planet value",
"game.calculator.modern.current": "current",
"game.calculator.modern.target": "target",
"game.calculator.modern.cost": "upgrade cost",
"game.calculator.modern.total": "total upgrade cost",
"game.calculator.unavailable": "—",
"game.calculator.invalid.empty": "name cannot be empty",
"game.calculator.invalid.too_long": "name is too long (30 characters max)",
"game.calculator.invalid.starts_with_special": "name cannot start with a special character",
"game.calculator.invalid.ends_with_special": "name cannot end with a special character",
"game.calculator.invalid.consecutive_specials": "too many special characters in a row",
"game.calculator.invalid.whitespace": "name cannot contain spaces",
"game.calculator.invalid.disallowed_character": "name contains disallowed characters",
"game.calculator.invalid.duplicate_name": "a ship class with this name already exists",
"game.calculator.invalid.drive_value": "drive must be 0 or ≥ 1",
"game.calculator.invalid.armament_value": "armament must be 0 or a positive integer",
"game.calculator.invalid.armament_not_integer": "armament must be an integer",
"game.calculator.invalid.weapons_value": "weapons must be 0 or ≥ 1",
"game.calculator.invalid.shields_value": "shields must be 0 or ≥ 1",
"game.calculator.invalid.cargo_value": "cargo must be 0 or ≥ 1",
"game.calculator.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
"game.calculator.invalid.all_zero": "at least one value must be nonzero",
"game.table.sciences.title": "sciences",
"game.table.sciences.column.name": "name",
"game.table.sciences.column.drive": "drive %",
+59
View File
@@ -338,6 +338,65 @@ const ru: Record<keyof typeof en, string> = {
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
"game.designer.ship_class.preview.unavailable": "—",
"game.calculator.title": "калькулятор классов кораблей",
"game.calculator.mode.ship": "калькулятор",
"game.calculator.mode.modernization": "модернизация",
"game.calculator.name.placeholder": "имя нового класса",
"game.calculator.name.existing": "ваши классы кораблей",
"game.calculator.action.create": "создать",
"game.calculator.action.delete": "удалить",
"game.calculator.col.ship": "корабль",
"game.calculator.col.tech": "технологии",
"game.calculator.field.drive": "двигатель",
"game.calculator.field.armament": "вооружённость",
"game.calculator.field.weapons": "оружие",
"game.calculator.field.shields": "защита",
"game.calculator.field.cargo": "трюм",
"game.calculator.load.label": "загрузка",
"game.calculator.load.empty": "пусто",
"game.calculator.load.full": "полная",
"game.calculator.load.custom": "своя",
"game.calculator.col.empty": "пустой",
"game.calculator.col.loaded": "гружёный",
"game.calculator.out.mass": "масса",
"game.calculator.out.speed": "скорость",
"game.calculator.out.attack": "атака",
"game.calculator.out.defense": "защита",
"game.calculator.out.bombing": "бомбардировка",
"game.calculator.out.cargo_capacity": "грузоподъёмность",
"game.calculator.planet.title": "планета",
"game.calculator.planet.none": "выберите свою планету на карте",
"game.calculator.planet.label": "планета {name} (#{number})",
"game.calculator.planet.mat": "MAT",
"game.calculator.planet.ships_per_turn": "кораблей / ход",
"game.calculator.planet.turns_per_ship": "ходов / корабль",
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
"game.calculator.modern.current": "текущий",
"game.calculator.modern.target": "целевой",
"game.calculator.modern.cost": "стоимость апгрейда",
"game.calculator.modern.total": "суммарная стоимость апгрейда",
"game.calculator.unavailable": "—",
"game.calculator.invalid.empty": "имя не может быть пустым",
"game.calculator.invalid.too_long": "имя слишком длинное (максимум 30 символов)",
"game.calculator.invalid.starts_with_special": "имя не может начинаться со спецсимвола",
"game.calculator.invalid.ends_with_special": "имя не может заканчиваться спецсимволом",
"game.calculator.invalid.consecutive_specials": "слишком много спецсимволов подряд",
"game.calculator.invalid.whitespace": "имя не может содержать пробелы",
"game.calculator.invalid.disallowed_character": "имя содержит недопустимые символы",
"game.calculator.invalid.duplicate_name": "класс корабля с таким именем уже существует",
"game.calculator.invalid.drive_value": "двигатель должен быть 0 или ≥ 1",
"game.calculator.invalid.armament_value": "вооружённость должна быть 0 или положительным целым",
"game.calculator.invalid.armament_not_integer": "вооружённость должна быть целым числом",
"game.calculator.invalid.weapons_value": "оружие должно быть 0 или ≥ 1",
"game.calculator.invalid.shields_value": "защита должна быть 0 или ≥ 1",
"game.calculator.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
"game.calculator.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
"game.table.sciences.title": "науки",
"game.table.sciences.column.name": "название",
"game.table.sciences.column.drive": "двигатель %",
@@ -179,14 +179,6 @@ destinations beats the duplication.
>
{i18n.t("game.view.mail")}
</button>
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-designer-ship-class"
onclick={() => go(`/games/${gameId}/designer/ship-class`)}
>
{i18n.t("game.view.designer.ship_class")}
</button>
<button
type="button"
role="menuitem"
+823 -14
View File
@@ -1,29 +1,838 @@
<!--
Phase 10 stub for the Calculator sidebar tool. Phase 30 wires the
real ship/path calculator. Until then the stub renders a localised
`coming soon` paragraph with a stable testid that later phases can
replace without touching navigation.
Phase 30 ship-class calculator. Replaces the Phase 17/18 standalone
designer: it fuses the ship design blocks, the live derived results
(mass, speed, attack, defence, bombing), and a planet build-rate readout
into one sidebar tool, and adds single-target goal-seek — the player pins
one result and the model back-solves the single input it claims (see
`lib/calculator/calc-model.ts`). A second mode reuses the design area to
price ship-class modernization. All math comes from `pkg/calc` through
the `Core` WASM bridge; this component only holds input state and renders.
State is component-local: the sidebar keeps this tab mounted while the
player navigates between active views, so inputs persist across view
switches per the global state-preservation rule.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import { getContext } from "svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import type { ReportPlanet, ShipClassSummary } from "../../api/game-state";
import {
validateShipClass,
type ShipClassInvalidReason,
} from "$lib/util/ship-class-validation";
import {
computeCalculator,
computePlanetBuild,
type LockableOutputId,
type LoadMode,
} from "$lib/calculator/calc-model";
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
import ShipDesignArea, {
type TechKey,
} from "$lib/calculator/ship-design-area.svelte";
import { reachStore } from "$lib/calculator/reach.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
type Mode = "ship" | "modernization";
let mode = $state<Mode>("ship");
let name = $state("");
let blocks = $state({ drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 });
let techValues = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 });
let techOverridden = $state<Record<TechKey, boolean>>({
drive: false,
weapons: false,
shields: false,
cargo: false,
});
let targetTech = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 });
let loadMode = $state<LoadMode>("full");
let customLoad = $state(0);
let lock = $state<LockableOutputId | null>(null);
let lockValue = $state(0);
let matOverridden = $state(false);
let matValue = $state(0);
let loadedExisting = $state<string | null>(null);
const core = $derived(coreHandle?.core ?? null);
const report = $derived(rendered?.report ?? null);
const localShipClass = $derived<ShipClassSummary[]>(
report?.localShipClass ?? [],
);
const existingNames = $derived(localShipClass.map((c) => c.name));
const playerTech = $derived({
drive: report?.localPlayerDrive ?? 0,
weapons: report?.localPlayerWeapons ?? 0,
shields: report?.localPlayerShields ?? 0,
cargo: report?.localPlayerCargo ?? 0,
});
const techKeys: TechKey[] = ["drive", "weapons", "shields", "cargo"];
// Non-overridden tech levels track the player's current tech; the
// effect resets them whenever the report (history snapshot included)
// changes, so the calculator reflects the right turn's tech.
$effect(() => {
for (const k of techKeys) {
if (!techOverridden[k]) techValues[k] = playerTech[k];
}
});
// Seed the modernization target with the player's current tech once
// the report has loaded; afterwards it is the player's to edit.
let targetSeeded = false;
$effect(() => {
if (targetSeeded) return;
if (
playerTech.drive ||
playerTech.weapons ||
playerTech.shields ||
playerTech.cargo
) {
targetTech = { ...playerTech };
targetSeeded = true;
}
});
const result = $derived(
computeCalculator(
{
blocks,
driveTech: techValues.drive,
weaponsTech: techValues.weapons,
shieldsTech: techValues.shields,
cargoTech: techValues.cargo,
loadMode,
customLoad,
lock: lock === null ? null : { output: lock, value: lockValue },
},
core,
),
);
// Selected own planet (MVP: own planets only).
const selectedPlanet = $derived.by<ReportPlanet | null>(() => {
const sel = selection?.selected;
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
const planet = report?.planets.find((p) => p.number === sel.id) ?? null;
if (planet === null || planet.kind !== "local") return null;
return planet;
});
$effect(() => {
if (!matOverridden) {
matValue = selectedPlanet?.materialsStockpile ?? 0;
}
});
const planetBuild = $derived.by(() => {
if (selectedPlanet === null) return null;
const emptyMass = result.outputs?.emptyMass;
if (emptyMass === undefined) return null;
return computePlanetBuild(
{
shipMass: emptyMass,
freeIndustry: selectedPlanet.freeIndustry ?? 0,
material: matValue,
resources: selectedPlanet.resources ?? 0,
},
core,
);
});
// Publish the selected planet's reach (loaded speed) so the map view
// can draw 13 reach circles. Cleared when the design is invalid, no
// own planet is selected, or the calculator is in modernization mode.
$effect(() => {
const out = result.outputs;
if (mode === "ship" && selectedPlanet !== null && out !== null) {
reachStore.set(
{ x: selectedPlanet.x, y: selectedPlanet.y },
out.speedLoaded,
);
} else {
reachStore.clear();
}
return () => reachStore.clear();
});
const nameInvalidKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
empty: "game.calculator.invalid.empty",
too_long: "game.calculator.invalid.too_long",
starts_with_special: "game.calculator.invalid.starts_with_special",
ends_with_special: "game.calculator.invalid.ends_with_special",
consecutive_specials: "game.calculator.invalid.consecutive_specials",
whitespace: "game.calculator.invalid.whitespace",
disallowed_character: "game.calculator.invalid.disallowed_character",
duplicate_name: "game.calculator.invalid.duplicate_name",
drive_value: "game.calculator.invalid.drive_value",
armament_value: "game.calculator.invalid.armament_value",
armament_not_integer: "game.calculator.invalid.armament_not_integer",
weapons_value: "game.calculator.invalid.weapons_value",
shields_value: "game.calculator.invalid.shields_value",
cargo_value: "game.calculator.invalid.cargo_value",
armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
all_zero: "game.calculator.invalid.all_zero",
};
const nameValidation = $derived(
validateShipClass({ name, ...result.blocks }, { existingNames }),
);
const createMessage = $derived(
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
);
const canCreate = $derived(nameValidation.ok && draft !== undefined);
const canDelete = $derived(
loadedExisting !== null &&
existingNames.includes(loadedExisting) &&
draft !== undefined,
);
// Per-block modernization upgrade cost (current tech → target tech).
const modernCosts = $derived.by(() => {
if (core === null) return null;
const weaponsMass = core.weaponsBlockMass({
weapons: blocks.weapons,
armament: blocks.armament,
});
const rows: { key: TechKey; mass: number }[] = [
{ key: "drive", mass: blocks.drive },
{ key: "weapons", mass: weaponsMass ?? 0 },
{ key: "shields", mass: blocks.shields },
{ key: "cargo", mass: blocks.cargo },
];
const perBlock = rows.map((r) => ({
key: r.key,
cost: core.blockUpgradeCost({
blockMass: r.mass,
currentTech: techValues[r.key],
targetTech: targetTech[r.key],
}),
}));
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
return { perBlock, total };
});
function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable");
}
return value.toLocaleString(undefined, { maximumFractionDigits: 3 });
}
function onTechInput(key: TechKey): void {
techOverridden[key] = true;
}
function onResetTech(key: TechKey): void {
techOverridden[key] = false;
}
function onMatInput(): void {
matOverridden = true;
}
function resetMat(): void {
matOverridden = false;
}
function lockOutput(output: LockableOutputId): void {
if (lock !== null) return;
lockValue = result.outputs?.[output] ?? 0;
lock = output;
}
function unlock(): void {
lock = null;
}
function loadExisting(clsName: string): void {
const cls = localShipClass.find((c) => c.name === clsName);
if (cls === undefined) return;
blocks = {
drive: cls.drive,
armament: cls.armament,
weapons: cls.weapons,
shields: cls.shields,
cargo: cls.cargo,
};
name = cls.name;
loadedExisting = cls.name;
lock = null;
}
function resetToNew(): void {
blocks = { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
name = "";
loadedExisting = null;
lock = null;
}
// React to the ship-classes table / bottom-tabs asking to load a
// class (or start a fresh design) into the calculator. The layout
// flips the sidebar to this tab in parallel.
let lastLoadToken = 0;
$effect(() => {
const token = calculatorLoadRequest.token;
if (token === lastLoadToken) return;
lastLoadToken = token;
mode = "ship";
if (calculatorLoadRequest.name === null) resetToNew();
else loadExisting(calculatorLoadRequest.name);
});
async function create(): Promise<void> {
if (!nameValidation.ok || draft === undefined) return;
// Capture the validated draft before awaiting: adding the command
// re-projects `localShipClass`, which re-runs the `nameValidation`
// derived into a `duplicate_name` failure (the class now exists),
// leaving `nameValidation.value` undefined after the await.
const created = nameValidation.value;
await draft.add({
kind: "createShipClass",
id: crypto.randomUUID(),
name: created.name,
drive: created.drive,
armament: created.armament,
weapons: created.weapons,
shields: created.shields,
cargo: created.cargo,
});
loadedExisting = created.name;
}
async function deleteClass(): Promise<void> {
if (loadedExisting === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: loadedExisting,
});
loadedExisting = null;
}
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
emptyMass: i18n.t("game.calculator.out.mass"),
loadedMass: i18n.t("game.calculator.out.mass"),
speedEmpty: i18n.t("game.calculator.out.speed"),
speedLoaded: i18n.t("game.calculator.out.speed"),
attack: i18n.t("game.calculator.out.attack"),
defense: i18n.t("game.calculator.out.defense"),
});
</script>
<section class="tool" data-testid="sidebar-tool-calculator">
<h3>{i18n.t("game.sidebar.tab.calculator")}</h3>
<p>{i18n.t("game.sidebar.empty.calculator")}</p>
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
{#if lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}>
<input
type="number"
step="0.001"
bind:value={lockValue}
data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/>
<button
type="button"
class="lock active"
title={i18n.t("game.calculator.lock.reset")}
aria-label={i18n.t("game.calculator.lock.reset")}
data-testid={`calculator-unlock-${output}`}
onclick={unlock}
>
🔒
</button>
</span>
{:else}
<span class="cell">
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
<button
type="button"
class="lock"
disabled={lock !== null || value === undefined}
title={lock !== null
? i18n.t("game.calculator.lock.max")
: `${LOCK_LABELS[output]}`}
aria-label={LOCK_LABELS[output]}
data-testid={`calculator-lock-${output}`}
onclick={() => lockOutput(output)}
>
🔓
</button>
</span>
{/if}
{/snippet}
<section class="calculator" data-testid="sidebar-tool-calculator">
<div class="modes" role="tablist">
<button
type="button"
class:active={mode === "ship"}
data-testid="calculator-mode-ship"
onclick={() => (mode = "ship")}
>
{i18n.t("game.calculator.mode.ship")}
</button>
<button
type="button"
class:active={mode === "modernization"}
data-testid="calculator-mode-modernization"
onclick={() => (mode = "modernization")}
>
{i18n.t("game.calculator.mode.modernization")}
</button>
</div>
<div class="namebar">
<input
type="text"
class="name"
list="calculator-existing-classes"
placeholder={i18n.t("game.calculator.name.placeholder")}
maxlength="30"
bind:value={name}
oninput={() => (loadedExisting = null)}
onchange={() => loadExisting(name)}
aria-invalid={nameValidation.ok ? "false" : "true"}
data-testid="calculator-name"
/>
<datalist id="calculator-existing-classes">
{#each localShipClass as cls (cls.name)}
<option value={cls.name}></option>
{/each}
</datalist>
{#if mode === "ship"}
<button
type="button"
class="create"
disabled={!canCreate}
title={canCreate ? "" : createMessage}
data-testid="calculator-create"
onclick={() => void create()}
>
{i18n.t("game.calculator.action.create")}
</button>
{/if}
</div>
{#if mode === "ship" && canDelete}
<button
type="button"
class="delete"
data-testid="calculator-delete"
onclick={() => void deleteClass()}
>
{i18n.t("game.calculator.action.delete")} {loadedExisting}
</button>
{/if}
<ShipDesignArea
bind:blocks
resolved={result.blocks}
bind:techs={techValues}
{techOverridden}
computedInput={result.computedInput}
{onTechInput}
{onResetTech}
/>
{#if mode === "ship"}
<div class="load">
<span class="label">{i18n.t("game.calculator.load.label")}</span>
<div class="seg" role="group">
{#each LOAD_MODES as m (m)}
<button
type="button"
class:active={loadMode === m}
data-testid={`calculator-load-${m}`}
onclick={() => (loadMode = m)}
>
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
</button>
{/each}
</div>
{#if loadMode === "custom"}
<input
type="number"
step="0.01"
min="0"
class="custom-load"
bind:value={customLoad}
data-testid="calculator-custom-load"
/>
{/if}
</div>
<div class="results" data-testid="calculator-results">
<div class="rrow head">
<span></span>
<span class="col-head">{i18n.t("game.calculator.col.empty")}</span>
<span class="col-head">{i18n.t("game.calculator.col.loaded")}</span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.mass")}</span>
{@render lockable("emptyMass", result.outputs?.emptyMass)}
{@render lockable("loadedMass", result.outputs?.loadedMass)}
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.speed")}</span>
{@render lockable("speedEmpty", result.outputs?.speedEmpty)}
{@render lockable("speedLoaded", result.outputs?.speedLoaded)}
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.attack")}</span>
{@render lockable("attack", result.outputs?.attack)}
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.defense")}</span>
{@render lockable("defense", result.outputs?.defense)}
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.bombing")}</span>
<span class="cell">
<span class="val" data-testid="calculator-out-bombing">
{fmt(result.outputs?.bombing)}
</span>
</span>
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.cargo_capacity")}</span>
<span class="cell">
<span class="val" data-testid="calculator-out-cargo-capacity">
{fmt(result.outputs === null ? null : result.cargoCapacity)}
</span>
</span>
<span></span>
</div>
</div>
<div class="planet" data-testid="calculator-planet-area">
{#if selectedPlanet === null}
<p class="hint" data-testid="calculator-planet-none">
{i18n.t("game.calculator.planet.none")}
</p>
{:else}
<p class="planet-name" data-testid="calculator-planet-name">
{i18n.t("game.calculator.planet.label", {
name: selectedPlanet.name,
number: String(selectedPlanet.number),
})}
</p>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
<span class="cell">
<input
type="number"
step="0.01"
min="0"
bind:value={matValue}
oninput={onMatInput}
data-testid="calculator-planet-mat"
/>
{#if matOverridden}
<button
type="button"
class="lock active"
title={i18n.t("game.calculator.mat.reset")}
aria-label={i18n.t("game.calculator.mat.reset")}
data-testid="calculator-mat-reset"
onclick={resetMat}
>
🔒
</button>
{/if}
</span>
<span></span>
</div>
<dl class="planet-stats">
<div>
<dt>{i18n.t("game.calculator.planet.ships_per_turn")}</dt>
<dd data-testid="calculator-ships-per-turn">
{fmt(planetBuild?.shipsPerTurn)}
</dd>
</div>
<div>
<dt>{i18n.t("game.calculator.planet.turns_per_ship")}</dt>
<dd data-testid="calculator-turns-per-ship">
{fmt(planetBuild?.turnsPerShip ?? null)}
</dd>
</div>
</dl>
{/if}
</div>
{:else}
<div class="modern" data-testid="calculator-modernization">
<div class="rrow head">
<span></span>
<span class="col-head">{i18n.t("game.calculator.modern.target")}</span>
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div>
{#each modernCosts?.perBlock ?? [] as row (row.key)}
<div class="rrow">
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
<span class="cell">
<input
type="number"
step="0.001"
min="0"
bind:value={targetTech[row.key]}
data-testid={`calculator-target-${row.key}`}
/>
</span>
<span class="cell">
<span class="val" data-testid={`calculator-modern-cost-${row.key}`}>
{fmt(row.cost)}
</span>
</span>
</div>
{/each}
<div class="rrow total">
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
<span></span>
<span class="cell">
<span class="val" data-testid="calculator-modern-total">
{fmt(modernCosts?.total)}
</span>
</span>
</div>
</div>
{/if}
</section>
<style>
.tool {
padding: 1rem;
.calculator {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem;
font-family: system-ui, sans-serif;
}
.tool h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
.modes {
display: flex;
gap: 0.25rem;
}
.tool p {
.modes button {
flex: 1;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.modes button.active {
color: #e8eaf6;
border-color: #6d8cff;
background: #11162a;
}
.namebar {
display: flex;
gap: 0.35rem;
}
.name {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.4rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.name[aria-invalid="true"] {
border-color: #d97a7a;
}
.create,
.delete {
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.create:not(:disabled):hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete {
color: #d97a7a;
align-self: flex-start;
}
.load {
display: flex;
align-items: center;
gap: 0.4rem;
}
.seg {
display: flex;
gap: 0.15rem;
}
.seg button {
font: inherit;
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.seg button.active {
color: #e8eaf6;
border-color: #6d8cff;
}
.custom-load {
width: 4rem;
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.results,
.modern {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rrow {
display: grid;
grid-template-columns: 4.5rem 1fr 1fr;
align-items: center;
gap: 0.35rem;
}
.col-head {
color: #8890b0;
font-size: 0.7rem;
text-align: center;
}
.label {
color: #aab;
font-size: 0.8rem;
}
.cell {
display: flex;
align-items: center;
gap: 0.2rem;
justify-content: flex-end;
}
.cell .val {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.cell input {
width: 100%;
min-width: 0;
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.cell.locked input {
color: #9fb0ff;
border-color: #6d8cff;
}
.cell.infeasible input {
border-color: #d97a7a;
color: #f0a0a0;
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:not(:disabled):hover {
opacity: 1;
}
.lock:disabled {
cursor: not-allowed;
opacity: 0.2;
}
.planet {
border-top: 1px solid #20253a;
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hint {
margin: 0;
color: #888;
font-size: 0.8rem;
}
.planet-name {
margin: 0;
font-size: 0.8rem;
color: #cdd3f0;
}
.planet-stats {
margin: 0;
display: grid;
grid-template-columns: 1fr max-content;
row-gap: 0.2rem;
column-gap: 0.5rem;
}
.planet-stats div {
display: contents;
}
.planet-stats dt {
color: #aab;
font-size: 0.8rem;
}
.planet-stats dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.rrow.total .label {
color: #cdd3f0;
}
</style>
@@ -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