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
@@ -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>