diff --git a/ui/docs/calculator-ux.md b/ui/docs/calculator-ux.md index b354901..7bb8ec4 100644 --- a/ui/docs/calculator-ux.md +++ b/ui/docs/calculator-ux.md @@ -30,12 +30,19 @@ in as a per-ship result rather than a separate mode. override at or above their current tech. Clicking the closed padlock resets to the default. The padlock slot is always reserved, so the column width does not shift as the lock state toggles. The - four ship-class blocks (drive, weapons, shields, cargo) use a smart - keyboard step that respects the engine value rule (`0` or `≥ 1`): - ArrowUp from 0 jumps straight to 1, otherwise +0.1; ArrowDown from - 1 collapses to 0, otherwise −0.1, never producing an invalid value - in `(0, 1)`. The native spinner is hidden on these inputs (it would - produce invalid intermediates); armament keeps its native step 1. + inherited tech value reads through the same 3-decimal `Ceil3` + formatter the report uses, so the column lines up with derived + values. **Every numeric input in the calculator hides the native + spinner and drives stepping through ArrowUp / ArrowDown.** This keeps + the column widths stable, makes the inputs read consistently, and + gives each row a step that matches its purpose. The four ship-class + blocks (drive, weapons, shields, cargo) use a smart step that + respects the engine value rule (`0` or `≥ 1`): ArrowUp from 0 jumps + straight to 1, otherwise +0.1; ArrowDown from 1 collapses to 0, + otherwise −0.1, never producing an invalid value in `(0, 1)`. + Armament steps ±1 (clamped at 0). Tech, planet MAT, custom load, + lock value, and modernization target tech each step by their natural + grain (±0.001 for tech and lock values, ±0.01 for MAT and load). 2. **Calculator area** — derived results: empty/loaded mass, empty/ loaded speed, attack, defence, bombing (per ship), cargo capacity. A load toggle (empty / full / custom) sets the cargo load (in cargo @@ -48,8 +55,10 @@ in as a per-ship result rather than a separate mode. turns per ship). The MAT follows the same lock idiom as the tech cells: the planet number renders with an open padlock, clicking opens an input with a closed padlock, and the closed padlock resets - to the planet value. The realistic multi-turn forecast with CAP/COL - supply is planned (see ../ROADMAP.md). + to the planet value. The MAT label reads through the same 3-decimal + `Ceil3` formatter, matching the rest of the calculator's label + values. The realistic multi-turn forecast with CAP/COL supply is + planned (see ../ROADMAP.md). ## Locks and goal-seek @@ -80,16 +89,19 @@ pinned by the player, click to reset*: Only **one** result may be locked at a time (the others' lock affordances disable with a tooltip). An unreachable target — e.g. a - speed above the stripped-hull ceiling `20 × driveTech`, or a - solved block that fails the value rules — leaves the locked cell in a - red error state and does not apply. Inverse solving lives in - `pkg/calc/solve.go`; the bisection for defence → shields is the only - non-analytic case. Locking a speed is disabled when the drive block is - zero (a deliberately immobile ship has no speed to back-solve). With - the drive block as the only non-zero mass the displayed speed equals - the ceiling exactly (every positive drive gives the same speed), so - the solver accepts that ceiling target as a feasible lock and any - positive drive solves it. + speed above the stripped-hull ceiling `20 × driveTech`, or a solved + block that fails the value rules (a DWSC value in the `(0, 1)` gap) + — leaves the locked cell in a red error state and does not apply. + When that happens the claimed block is **not** back-solved into the + invalid range; the design preview keeps reading the user's typed + values, so the row never silently shows a sub-1 block. Inverse + solving lives in `pkg/calc/solve.go`; the bisection for defence → + shields is the only non-analytic case. Locking a speed is disabled + when the drive block is zero (a deliberately immobile ship has no + speed to back-solve). With the drive block as the only non-zero mass + the displayed speed equals the ceiling exactly (every positive drive + gives the same speed), so the solver accepts that ceiling target as + a feasible lock and any positive drive solves it. ## Validation and display @@ -119,6 +131,18 @@ overlay reflects the change immediately. Ship classes are immutable after creation (per `game/rules.txt`), so there is no edit — only Create-new and Delete. +Selecting a class from the dropdown loads it **immediately**, the +moment the option is clicked. (Native `change` only fires on blur in +Firefox; switching the load trigger to `input` makes the load +synchronous everywhere, since the `InputEvent.inputType` flags a +datalist replacement as `"insertReplacementText"` in Chromium / WebKit +or `undefined` in Firefox — keyboard typing always carries a typing +`inputType`.) If the live blocks differ from the previously loaded +class (or, when nothing is loaded, from the empty defaults), the +calculator first asks `Discard unsaved changes and load class «…»?` +through a `window.confirm`; declining reverts the name field and +leaves the current blocks untouched. + ## Reach circles When an own planet is selected in calculator mode, the calculator diff --git a/ui/frontend/src/lib/calculator/calc-model.ts b/ui/frontend/src/lib/calculator/calc-model.ts index bb4c586..45d76c1 100644 --- a/ui/frontend/src/lib/calculator/calc-model.ts +++ b/ui/frontend/src/lib/calculator/calc-model.ts @@ -92,6 +92,18 @@ export interface CalculatorResult { 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, @@ -225,8 +237,18 @@ export function computeCalculator( if (solved === null) { lockFeasible = false; } else { - blocks[claimed] = solved; - computedInput = claimed; + // 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; + } } } } diff --git a/ui/frontend/src/lib/calculator/ship-design-area.svelte b/ui/frontend/src/lib/calculator/ship-design-area.svelte index fc1be7f..739f47a 100644 --- a/ui/frontend/src/lib/calculator/ship-design-area.svelte +++ b/ui/frontend/src/lib/calculator/ship-design-area.svelte @@ -47,6 +47,11 @@ calculator math — so the ship-group upgrade flow can reuse it later. techFloor: TechState; computedInput?: ClaimedInput | null; blocksReadonly?: boolean; + // Formatter applied to the read-only tech value and to the + // resolved (goal-seek) ship-block value. Same `fmt` as the + // rest of the calculator, passed in so the design area stays + // presentational and the parent owns the rounding policy. + formatNumber: (value: number) => string; onTechInput: (key: TechKey) => void; onResetTech: (key: TechKey) => void; }; @@ -58,6 +63,7 @@ calculator math — so the ship-group upgrade flow can reuse it later. techFloor, computedInput = null, blocksReadonly = false, + formatNumber, onTechInput, onResetTech, }: Props = $props(); @@ -94,7 +100,9 @@ calculator math — so the ship-group upgrade flow can reuse it later. // cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the // native 0.01 step would produce invalid intermediates like 0.01. // Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0; - // otherwise −0.1 down to 1, clamped at 0. Armament keeps native step 1. + // otherwise −0.1 down to 1, clamped at 0. Armament uses a plain + // integer step (±1, clamped at 0) so it follows the same + // JS-driven idiom and we can hide the native spinner uniformly. function bumpBlock(value: number, dir: 1 | -1): number { if (dir === 1) { if (value < 1) return 1; @@ -103,31 +111,46 @@ calculator math — so the ship-group upgrade flow can reuse it later. if (value <= 1) return 0; return Math.round((value - 0.1) * 10) / 10; } + function bumpArmament(value: number, dir: 1 | -1): number { + const next = Math.trunc(value) + dir; + return next < 0 ? 0 : next; + } function onBlockKey( event: KeyboardEvent, key: keyof DesignBlocksState, + smart: boolean, ): void { - if (event.key === "ArrowUp") { - event.preventDefault(); - blocks[key] = bumpBlock(blocks[key], 1); - } else if (event.key === "ArrowDown") { - event.preventDefault(); - blocks[key] = bumpBlock(blocks[key], -1); - } + const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0; + if (dir === 0) return; + event.preventDefault(); + blocks[key] = smart + ? bumpBlock(blocks[key], dir) + : bumpArmament(blocks[key], dir); + } + // Tech / modernization-target inputs all use the same ±0.001 step + // with a per-row floor; lifted into a helper so the parent can + // reuse it (modernization area in `calculator-tab`). + function bumpTech(event: KeyboardEvent, key: TechKey): void { + const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0; + if (dir === 0) return; + event.preventDefault(); + const current = techs[key]; + const next = Math.round((current + dir * 0.001) * 1000) / 1000; + const floor = techFloor[key]; + techs[key] = next < floor ? floor : next; } const BLOCK_ROWS: { key: keyof DesignBlocksState; label: () => string; - step: string; tech: TechKey | null; smartStep: boolean; }[] = [ - { key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.1", tech: "drive", smartStep: true }, - { key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null, smartStep: false }, - { key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.1", tech: "weapons", smartStep: true }, - { key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.1", tech: "shields", smartStep: true }, - { key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.1", tech: "cargo", smartStep: true }, + { key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true }, + { key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false }, + { key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true }, + { key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true }, + { key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), tech: "cargo", smartStep: true }, ]; const techInputEls: Partial> = {}; @@ -152,31 +175,27 @@ calculator math — so the ship-group upgrade flow can reuse it later. {row.label()} {#if isComputed} {:else} onBlockKey(e, row.key) - : null} + onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)} /> {/if} {#if row.tech !== null} @@ -185,14 +204,15 @@ calculator math — so the ship-group upgrade flow can reuse it later. {#if techOverridden[techKey]} bumpTech(e, techKey)} />