feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet. pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm. Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store. Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user