// 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 = { 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; } // isClaimedBlockValid checks that a solver result, before we apply it // to the resolved blocks, satisfies the same per-field rules the live // validator enforces on user-typed values (`pkg/calc/validator.go` / // `lib/util/ship-class-validation`). The four claimable blocks all // share the DWSC rule, so a single predicate suffices. Used to flag // a goal-seek target as infeasible when the only block that would // reach it falls in the (0, 1) gap. function isClaimedBlockValid(solved: number): boolean { if (!Number.isFinite(solved)) return false; return solved === 0 || solved >= 1; } 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 { // The solver may produce a value that is mathematically // correct yet rejected by the ship-class value rules — // most commonly a DWSC block in the (0, 1) gap. Surface // that as an infeasible lock so the lock input flips // red and the outputs are suppressed, instead of // silently showing an invalid design. if (!isClaimedBlockValid(solved)) { 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, }; }