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,
};
}