cbf7f65916
Owner review on PR #61: - п.9 (option B). Hide the native spinner on EVERY numeric input in the calculator (DWSC blocks, armament, tech, planet MAT, custom load, lock value, modernization target tech) and drive every step through ArrowUp / ArrowDown. The column widths stay stable and the inputs read consistently across the whole row. The ship blocks keep the smart (0 ↔ 1) jump on ArrowUp/ArrowDown; armament steps ±1 with a JS handler instead of relying on the native spinner. Other inputs step by their natural grain (±0.001 for tech / lock, ±0.01 for MAT / load). - п.10. Tech-level labels (`tech-val`) and the planet MAT label (`mat-val`) now read through the same `Ceil3` formatter as the derived results, so plain-text numeric values share the report's 3-decimal tabular formatting. The design-area component receives `formatNumber` as a prop; the resolved (goal-seek) cell uses the same formatter, so the read-only computed value matches the rest of the row. - п.12. `computeCalculator` now validates the back-solved block against the same DWSC rule the live validator enforces (`0` or `≥ 1`). When the solver lands in the `(0, 1)` gap (e.g. attack 0.5 / weaponsTech 1.5 → weapons 0.333…) the lock is flagged infeasible — the lock input flips red and the claimed block is NOT back-solved into the invalid range, so the design preview keeps reading the user's own typed values instead of silently showing a sub-1 block. - new. Selecting an existing ship class from the name datalist now loads it immediately. `change` fires only on blur in Firefox, which is why the previous behaviour looked delayed; switching the load to `oninput` with an `InputEvent.inputType` check makes the load synchronous everywhere (datalist replacement carries `"insertReplacementText"` in Chromium / WebKit, `undefined` in Firefox; keyboard typing always carries a typing `inputType`). Before loading we compare the live blocks to the previously loaded class (or to the empty defaults) and, if they differ, ask through a `window.confirm`. On decline we revert the name field and leave the design untouched. Tests: calculator-tab and calc-model gain six cases (armament step, tech/MAT formatter labels, lock infeasible on (0, 1) for both attack→weapons and emptyMass→cargo, lock-value Arrow step, dropdown immediate load + confirm-blocks-load + confirm-allows-load), all 779 vitest tests green. docs/calculator-ux.md follows the new behaviour. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
// 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;
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|