From e9b904332e1cca5d255c04dc70365c96b10b29c0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 26 May 2026 14:30:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20calculator=20polish=20=E2=80=94=20sm?= =?UTF-8?q?art=20input=20steps,=20unified=20tech/MAT=20lock=20idiom,=20tec?= =?UTF-8?q?h=20floor,=20speed-lock=20ceiling=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only case (every positive drive solves it), so locking the displayed speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible". - ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide the native spinner so it cannot produce invalid (0, 1) values; armament keeps its native step 1. - Tech and planet MAT cells follow the same lock idiom as goal-seek locks: open padlock (🔓) over the inherited value → click to open an input with a closed padlock (🔒). The padlock slot is always reserved, so the column width is stable. - Tech overrides (design area and modernization target) are floored at the player's current tech on this turn — a lower value is flagged as invalid. --- pkg/calc/solve.go | 21 ++- pkg/calc/solve_test.go | 20 ++- ui/docs/calculator-ux.md | 42 ++++-- .../lib/calculator/ship-design-area.svelte | 141 +++++++++++++++--- ui/frontend/src/lib/i18n/locales/en.ts | 3 + ui/frontend/src/lib/i18n/locales/ru.ts | 3 + .../src/lib/sidebar/calculator-tab.svelte | 78 +++++++--- ui/frontend/tests/calc-model.test.ts | 19 +++ ui/frontend/tests/calculator-tab.test.ts | 102 +++++++++++++ .../tests/e2e/game-shell-inspector.spec.ts | 3 + ui/frontend/tests/fake-core.ts | 7 +- 11 files changed, 384 insertions(+), 55 deletions(-) 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..b354901 100644 --- a/ui/docs/calculator-ux.md +++ b/ui/docs/calculator-ux.md @@ -24,8 +24,18 @@ 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 + 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. 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 @@ -35,17 +45,27 @@ in as a per-ship result rather than a separate mode. the load is pinned to empty and the toggle is disabled. 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 + 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). ## 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 +80,16 @@ 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 + 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). + 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 diff --git a/ui/frontend/src/lib/calculator/ship-design-area.svelte b/ui/frontend/src/lib/calculator/ship-design-area.svelte index fa2a7b4..fc1be7f 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 @@
@@ -103,6 +153,7 @@ calculator math — so the ship-group upgrade flow can reuse it later. {#if isComputed} onBlockKey(e, row.key) + : null} /> {/if} {#if row.tech !== null} {@const techKey = row.tech} - onTechInput(techKey)} - aria-invalid={techError(techKey) !== "" ? "true" : "false"} - title={techError(techKey)} - data-testid={`calculator-tech-${techKey}`} - /> {#if techOverridden[techKey]} + + {:else} + + {techs[techKey]} + + {/if} {:else} @@ -193,6 +265,18 @@ calculator math — so the ship-group upgrade flow can reuse it later. border-radius: 3px; font-variant-numeric: tabular-nums; } + /* Drive/weapons/shields/cargo use the JS-driven smart step (0→1 jump + then 0.1 increments) for keyboard arrows; hide the native spinner + on those inputs so it cannot produce invalid 0.01 intermediates. */ + input.smart-step::-webkit-inner-spin-button, + input.smart-step::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + input.smart-step { + -moz-appearance: textfield; + appearance: textfield; + } input[data-computed="true"], input[readonly] { color: var(--color-accent); @@ -206,6 +290,14 @@ calculator math — so the ship-group upgrade flow can reuse it later. align-items: center; gap: 0.2rem; } + .tech-val { + flex: 1; + min-width: 0; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + text-align: right; + padding: 0.2rem 0.35rem; + } .lock { flex: none; padding: 0; @@ -214,5 +306,10 @@ calculator math — so the ship-group upgrade flow can reuse it later. background: transparent; border: 0; cursor: pointer; + opacity: 0.5; + } + .lock.active, + .lock:hover { + opacity: 1; } diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 3fc975d..bd9fab8 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -393,7 +393,9 @@ const en = { "game.calculator.lock.reset": "locked — click to release to the computed value", "game.calculator.lock.infeasible": "this target cannot be reached with the current design", "game.calculator.lock.max": "release the locked result first — one result at a time", + "game.calculator.tech.override": "click to override your current tech", "game.calculator.tech.reset": "overridden — click to reset to your current tech", + "game.calculator.mat.override": "click to override the planet value", "game.calculator.mat.reset": "overridden — click to reset to the planet value", "game.calculator.modern.current": "current", "game.calculator.modern.target": "target", @@ -418,6 +420,7 @@ const en = { "game.calculator.invalid.all_zero": "at least one value must be nonzero", "game.calculator.invalid.negative": "value cannot be negative", "game.calculator.invalid.tech_value": "tech level cannot be negative", + "game.calculator.invalid.tech_below_current": "tech level cannot be below your current tech this turn", "game.calculator.invalid.load_over_capacity": "load exceeds the ship's cargo capacity", "game.calculator.lock.no_drive": "set a non-zero drive before locking speed", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index dc33c27..a7222f6 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -394,7 +394,9 @@ const ru: Record = { "game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение", "game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах", "game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз", + "game.calculator.tech.override": "нажмите, чтобы задать свой технологический уровень", "game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии", + "game.calculator.mat.override": "нажмите, чтобы задать своё значение MAT", "game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты", "game.calculator.modern.current": "текущий", "game.calculator.modern.target": "целевой", @@ -419,6 +421,7 @@ const ru: Record = { "game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым", "game.calculator.invalid.negative": "значение не может быть отрицательным", "game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным", + "game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу", "game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля", "game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость", diff --git a/ui/frontend/src/lib/sidebar/calculator-tab.svelte b/ui/frontend/src/lib/sidebar/calculator-tab.svelte index d306441..f76be4b 100644 --- a/ui/frontend/src/lib/sidebar/calculator-tab.svelte +++ b/ui/frontend/src/lib/sidebar/calculator-tab.svelte @@ -14,7 +14,7 @@ switch (the inspector auto-opens on a planet click) — the calculator is a long-lived planning tool. `ensureGame` resets it when the game changes. -->