diff --git a/pkg/calc/solve.go b/pkg/calc/solve.go index 44b5f57..df6abdc 100644 --- a/pkg/calc/solve.go +++ b/pkg/calc/solve.go @@ -22,12 +22,25 @@ func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) { // DriveForSpeed returns the drive block that yields targetSpeed for a // ship whose mass excluding the drive block is restMass, at drive tech // level driveTech, inverting [Speed] composed with [DriveEffective]. -// Speed approaches but never reaches the stripped-hull ceiling -// 20*driveTech, so a target at or above the ceiling (or a non-positive -// target or tech level) is infeasible. +// With a positive restMass the speed approaches but never reaches the +// stripped-hull ceiling 20*driveTech, so a target at or above the +// ceiling is infeasible. With restMass==0 the drive block carries no +// other mass: every positive drive yields exactly the ceiling speed, so +// the ceiling target is the only feasible one and any positive drive +// (canonically 1) solves it. Non-positive targetSpeed or driveTech are +// always infeasible. func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) { + if driveTech <= 0 || targetSpeed <= 0 { + return 0, false + } ceiling := 20 * driveTech - if driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling { + if restMass <= 0 { + if targetSpeed != ceiling { + return 0, false + } + return 1, true + } + if targetSpeed >= ceiling { return 0, false } return targetSpeed * restMass / (ceiling - targetSpeed), true diff --git a/pkg/calc/solve_test.go b/pkg/calc/solve_test.go index a032fce..bb7862c 100644 --- a/pkg/calc/solve_test.go +++ b/pkg/calc/solve_test.go @@ -24,12 +24,30 @@ func TestDriveForSpeed(t *testing.T) { if !ok || math.Abs(got-drive) > 1e-9 { t.Errorf("DriveForSpeed round-trip = %v (ok=%v), want %v", got, ok, drive) } - // Speed can never reach the stripped-hull ceiling 20*driveTech. + // With a positive restMass speed can never reach 20*driveTech. if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok { t.Error("DriveForSpeed at the speed ceiling should be infeasible") } } +func TestDriveForSpeedZeroRest(t *testing.T) { + // With restMass==0 the only achievable speed is the stripped-hull + // ceiling 20*driveTech; any positive drive reaches it. Off-ceiling + // targets are infeasible. + const driveTech = 1.5 + ceiling := 20 * driveTech + got, ok := calc.DriveForSpeed(ceiling, driveTech, 0) + if !ok || got <= 0 { + t.Errorf("DriveForSpeed(ceiling, _, 0) = %v (ok=%v), want positive", got, ok) + } + if _, ok := calc.DriveForSpeed(ceiling/2, driveTech, 0); ok { + t.Error("DriveForSpeed(below ceiling, _, 0) should be infeasible") + } + if _, ok := calc.DriveForSpeed(ceiling+1, driveTech, 0); ok { + t.Error("DriveForSpeed(above ceiling, _, 0) should be infeasible") + } +} + func TestShieldsForDefence(t *testing.T) { const shields, shieldsTech, restMass = 5.75, 1.0, 40.0 defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass) diff --git a/ui/docs/calculator-ux.md b/ui/docs/calculator-ux.md index bce9279..11fd44b 100644 --- a/ui/docs/calculator-ux.md +++ b/ui/docs/calculator-ux.md @@ -24,28 +24,60 @@ in as a per-ship result rather than a separate mode. 1. **Ship Class design area** — five blocks (drive, armament, weapons, shields, cargo) and four tech levels (drive, weapons, shields, - cargo). Tech defaults to the player's current tech and shows a lock - icon once overridden; clicking it resets to the default. + cargo). Tech defaults to the player's current tech: the cell renders + the inherited number with an open padlock; clicking the open lock + activates an input (closed padlock), where the player may type an + 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 + 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 units) that the loaded-column results use. At **full** the toggle shows the ship's cargo capacity; a **custom** load over that capacity is flagged as an error. With a zero cargo block there is no hold, so - the load is pinned to empty and the toggle is disabled. + the load is pinned to empty and the toggle is disabled. The bombing + and cargo-capacity rows have no goal-seek lock, but they still + reserve a hidden lock-slot placeholder so the value column stays + vertically aligned with the lockable rows above. 3. **Planet area** — when an own planet is selected on the map, shows its MAT (overridable) and the single-turn build rate (ships per turn, - turns per ship). The realistic multi-turn forecast with CAP/COL - supply is planned (see ../ROADMAP.md). + 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 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 -Two distinct lock semantics share one icon (a closed padlock; it only -appears once a value is pinned, click to release): +Two distinct lock semantics share one padlock affordance. Both follow +the same idiom — an open padlock (🔓) means *value is inherited / +derived, click to override*; a closed padlock (🔒) means *value is +pinned by the player, click to reset*: - **Override locks** on inputs that have a default — the four techs and - the planet MAT. Editing one overrides the default; the lock resets it. - Any number may be overridden at once. + the planet MAT. By default the cell shows the inherited number plus + an open padlock; clicking it switches to an input plus a closed + padlock for typing the override. Closing (clicking the closed + padlock) resets to the default. Any number may be overridden at once. + Tech overrides are floored at the player's current tech on this + turn — a lower value is flagged as invalid. The same floor applies + to the modernization target tech. - **Goal-seek locks** on derived results. Pinning a result back-solves the single input it claims, which then renders read-only (computed): @@ -60,12 +92,19 @@ appears once a value is pinned, click to release): Only **one** result may be locked at a time (the others' lock affordances disable with a tooltip). An unreachable target — e.g. a - speed at or 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). + 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 @@ -75,25 +114,43 @@ negative, the five blocks follow the engine value rules (`pkg/calc/validator.go`, surfaced per-field by `shipClassFieldErrors`), and a custom load may not exceed cargo capacity. -Every displayed number — the derived results and the goal-seek -back-solved input — is rounded **up** to three decimals through the -shared `pkg/calc/number.go.Ceil3` (bridged as `core.ceil3`), so a value -is never shown lower than it is (a speed of 5.0003 reads 5.001). The -engine keeps its own round-to-nearest `util.Fixed*`; `Ceil3` is a -display-only helper that lives in `pkg/calc` so the UI and Go share one -implementation. +Every displayed number — the derived results, the inherited tech / +planet MAT labels, and the goal-seek back-solved input — is rounded +**up** to three decimals through the shared `pkg/calc/number.go.Ceil3` +(bridged as `core.ceil3`) and always padded to three decimals so the +column reads the same on integers and fractions alike (a speed of 20 +shows as `20.000`, of 5.0003 as `5.001`). Labels and inputs use the +monospace stack from the design tokens (`--font-mono`) with +right-aligned, tabular numerals so values line up vertically across +rows. To match the display rule, every number input also refuses a +fourth decimal as the user types: typing `1.2345` clamps the input to +`1.234` on input. The engine keeps its own round-to-nearest +`util.Fixed*`; `Ceil3` is a display-only helper that lives in `pkg/calc` +so the UI and Go share one implementation. -## Create / load / delete +## Create / load The name field is a combobox over the player's existing classes. Picking an existing class loads it as a template (so you can tweak and Create a new one); Create is disabled while the name is invalid or duplicate -(reusing `lib/util/ship-class-validation.ts`). When a saved class is -loaded, a Delete affordance appears. Create / Delete reuse the existing -`createShipClass` / `removeShipClass` order-draft flow, so the optimistic -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. +(reusing `lib/util/ship-class-validation.ts`). Create reuses the existing +`createShipClass` order-draft flow, so the optimistic overlay reflects +the change immediately. Ship classes are immutable after creation (per +`game/rules.txt`), so there is no edit — only Create-new. Delete-class +lives in the ship-classes table (`lib/active-view/table-ship-classes.svelte`), +not the calculator. + +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 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 fa2a7b4..48e9cac 100644 --- a/ui/frontend/src/lib/calculator/ship-design-area.svelte +++ b/ui/frontend/src/lib/calculator/ship-design-area.svelte @@ -1,13 +1,17 @@