diff --git a/pkg/calc/number.go b/pkg/calc/number.go new file mode 100644 index 0000000..feb6661 --- /dev/null +++ b/pkg/calc/number.go @@ -0,0 +1,19 @@ +package calc + +import "math" + +// Ceil3 rounds num UP to three decimal places. The ship-class +// calculator displays every computed value (and every goal-seek +// back-solved input) through this so a result is never shown lower than +// it really is — e.g. a speed of 5.0003 reads as 5.001, not 5.000, which +// matters when a fraction of a light-year decides whether a ship clears +// the gap to a planet. It is display-only and lives here (rather than in +// the engine's round-to-nearest util.Fixed*) so the UI bridge can reach +// the one implementation through WASM. +// +// num is pre-rounded to nine decimals before the ceil so float64 +// representation noise does not push an exact value up a step (e.g. a +// computed 5.0 stored as 5.0000000002 stays 5.0). +func Ceil3(num float64) float64 { + return math.Ceil(math.Round(num*1e9)/1e6) / 1000 +} diff --git a/ui/core/calc/number.go b/ui/core/calc/number.go new file mode 100644 index 0000000..4a01475 --- /dev/null +++ b/ui/core/calc/number.go @@ -0,0 +1,10 @@ +package calc + +import "galaxy/calc" + +// Ceil3 wraps `calc.Ceil3` (`pkg/calc/number.go`): round up to three +// decimal places. The calculator formats every displayed number through +// this bridge so the UI and the canonical Go implementation agree. +func Ceil3(num float64) float64 { + return calc.Ceil3(num) +} diff --git a/ui/core/calc/number_test.go b/ui/core/calc/number_test.go new file mode 100644 index 0000000..10fca37 --- /dev/null +++ b/ui/core/calc/number_test.go @@ -0,0 +1,27 @@ +package calc_test + +import ( + "testing" + + source "galaxy/calc" + bridge "galaxy/core/calc" + + "github.com/stretchr/testify/assert" +) + +func TestCeil3Parity(t *testing.T) { + t.Parallel() + cases := []float64{0, 5, 5.0003, 4.2761, 139.29503, 0.0001, 1.9999999998} + for _, c := range cases { + assert.Equal(t, source.Ceil3(c), bridge.Ceil3(c)) + } +} + +func TestCeil3Values(t *testing.T) { + t.Parallel() + assert.Equal(t, 5.0, source.Ceil3(5.0)) + assert.Equal(t, 5.001, source.Ceil3(5.0003)) + assert.Equal(t, 4.277, source.Ceil3(4.2761)) + // Float noise just above an exact step stays put. + assert.Equal(t, 5.0, source.Ceil3(5.0000000002)) +} diff --git a/ui/docs/calc-bridge.md b/ui/docs/calc-bridge.md index 7e5e624..8a036eb 100644 --- a/ui/docs/calc-bridge.md +++ b/ui/docs/calc-bridge.md @@ -49,6 +49,7 @@ on the JS-side `globalThis.galaxyCore` (registered in | `shieldsForDefence` | `calc.ShieldsForDefence(targetDefence, sTech, restMass)` | `number\|null` | calculator goal-seek (defence → shields) | | `cargoForEmptyMass` | `calc.CargoForEmptyMass(targetEmptyMass, restMass)` | `number\|null` | calculator goal-seek (mass → cargo) | | `loadForFullMass` | `calc.LoadForFullMass(targetFullMass, emptyMass, cTech)` | `number\|null` | calculator goal-seek (loaded mass → load)| +| `ceil3` | `calc.Ceil3(value)` (`pkg/calc/number.go`) | `number` | calculator display rounding (round up to 3 dp) | `BombingPower` and the per-turn build loop are no longer engine-only: Phase 30 extracted `BombingPower` from diff --git a/ui/docs/calculator-ux.md b/ui/docs/calculator-ux.md index 376d8f1..81646c2 100644 --- a/ui/docs/calculator-ux.md +++ b/ui/docs/calculator-ux.md @@ -28,8 +28,11 @@ in as a per-ship result rather than a separate mode. icon once overridden; clicking it resets to the default. 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 that the - loaded-column results use. + 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. 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 @@ -61,7 +64,24 @@ appears once a value is pinned, click to release): 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. + non-analytic case. Locking a speed is disabled when the drive block is + zero (a deliberately immobile ship has no speed to back-solve). + +## Validation and display + +Every numeric input is validated independently and an offending one gets +a red border and a hover/tap tooltip with the reason: no value may be +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. ## Create / load / delete diff --git a/ui/frontend/src/lib/calculator/ship-design-area.svelte b/ui/frontend/src/lib/calculator/ship-design-area.svelte index 0d5c02e..6eb0473 100644 --- a/ui/frontend/src/lib/calculator/ship-design-area.svelte +++ b/ui/frontend/src/lib/calculator/ship-design-area.svelte @@ -8,7 +8,11 @@ The component is presentational — the parent owns the state and the calculator math — so the ship-group upgrade flow can reuse it later. -->