diff --git a/game/internal/controller/planet.go b/game/internal/controller/planet.go
index 8be2294..19af652 100644
--- a/game/internal/controller/planet.go
+++ b/game/internal/controller/planet.go
@@ -272,29 +272,19 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
if productionAvailable <= 0 {
return 0
}
- ships := uint(0)
- pa := productionAvailable
- var MATneed, totalCost float64
- for {
- MATneed = shipMass - float64(p.Material)
- if MATneed < 0 {
- MATneed = 0
- }
- totalCost = calc.ShipBuildCost(shipMass, float64(p.Material), float64(p.Resources))
- if pa < totalCost {
- progress := pa / totalCost
- pval := game.F(progress)
- if p.Production.Progress != nil {
- pval += *p.Production.Progress
- }
- p.Production.Progress = &pval
- fval := game.F(pa)
- p.Production.ProdUsed = &fval
- return ships
- } else {
- pa -= totalCost
- p.Mat(float64(p.Material) - shipMass + MATneed)
- ships += 1
- }
+ ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn(
+ productionAvailable,
+ float64(p.Material),
+ float64(p.Resources),
+ shipMass,
+ )
+ p.Mat(materialLeft)
+ pval := game.F(progress)
+ if p.Production.Progress != nil {
+ pval += *p.Production.Progress
}
+ p.Production.Progress = &pval
+ used := game.F(productionUsed)
+ p.Production.ProdUsed = &used
+ return ships
}
diff --git a/game/internal/model/game/group.go b/game/internal/model/game/group.go
index 845c6d7..9c202c8 100644
--- a/game/internal/model/game/group.go
+++ b/game/internal/model/game/group.go
@@ -3,7 +3,6 @@ package game
import (
"fmt"
"galaxy/calc"
- "math"
"strings"
"github.com/google/uuid"
@@ -208,11 +207,12 @@ func (sg ShipGroup) Speed(st *ShipType) float64 {
// Мощность бомбардировки
func (sg ShipGroup) BombingPower(st *ShipType) float64 {
- return (math.Sqrt(st.Weapons.F()*sg.TechLevel(TechWeapons).F())/10. + 1.) *
- st.Weapons.F() *
- sg.TechLevel(TechWeapons).F() *
- float64(st.Armament) *
- float64(sg.Number)
+ return calc.BombingPower(
+ st.Weapons.F(),
+ sg.TechLevel(TechWeapons).F(),
+ float64(st.Armament),
+ float64(sg.Number),
+ )
}
func (sg ShipGroup) CargoString() string {
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/pkg/calc/planet.go b/pkg/calc/planet.go
index 237eca5..d7ff350 100644
--- a/pkg/calc/planet.go
+++ b/pkg/calc/planet.go
@@ -22,10 +22,11 @@ func PlanetProduceShipMass(L, Mat, Res float64) float64 {
// resources is expected to be positive in normal play; the helper
// guards against a non-positive value by collapsing the material-
// farming term to zero, which keeps callers numerically stable on
-// pathological synthetic data. Mirrors the per-iteration math inside
-// the engine's controller.ProduceShip so both surfaces — and the
-// legacy-report-to-json dev tool that needs to derive prod_used from
-// percent — share the same formula.
+// pathological synthetic data. [ProduceShipsInTurn] composes this cost
+// into the per-turn build loop that the engine's controller.ProduceShip
+// delegates to, so the engine, the calculator, and the
+// legacy-report-to-json dev tool (which derives prod_used from percent)
+// all share one formula.
func ShipBuildCost(shipMass, material, resources float64) float64 {
matNeed := shipMass - material
if matNeed < 0 {
@@ -37,3 +38,39 @@ func ShipBuildCost(shipMass, material, resources float64) float64 {
}
return ShipProductionCost(shipMass) + matFarm
}
+
+// ProduceShipsInTurn simulates one turn of ship production on a planet
+// that has productionAvailable production units to spend, a material
+// stockpile, a resources rating, building ships of empty mass shipMass.
+// It returns the number of whole ships completed this turn, the material
+// left afterwards, the production units spent on the next (still
+// incomplete) ship, and that ship's progress fraction in [0, 1).
+//
+// Each ship consumes shipMass units of material; any shortfall is farmed
+// through [ShipBuildCost] at the planet's resources rating, draining the
+// stockpile to zero before farming. The loop mirrors the engine's
+// per-turn build step so the calculator and the turn generator agree on
+// how many ships a planet yields. productionAvailable or shipMass that is
+// non-positive yields no ships and leaves the stockpile untouched.
+func ProduceShipsInTurn(
+ productionAvailable, material, resources, shipMass float64,
+) (ships uint, materialLeft, productionUsed, progress float64) {
+ if productionAvailable <= 0 || shipMass <= 0 {
+ return 0, material, 0, 0
+ }
+ pa := productionAvailable
+ mat := material
+ for {
+ matNeed := shipMass - mat
+ if matNeed < 0 {
+ matNeed = 0
+ }
+ totalCost := ShipBuildCost(shipMass, mat, resources)
+ if pa < totalCost {
+ return ships, mat, pa, pa / totalCost
+ }
+ pa -= totalCost
+ mat = mat - shipMass + matNeed
+ ships++
+ }
+}
diff --git a/pkg/calc/planet_test.go b/pkg/calc/planet_test.go
index ef2471e..0d0b0a4 100644
--- a/pkg/calc/planet_test.go
+++ b/pkg/calc/planet_test.go
@@ -61,3 +61,52 @@ func TestShipBuildCost(t *testing.T) {
})
}
}
+
+func TestProduceShipsInTurn(t *testing.T) {
+ cases := []struct {
+ name string
+ productionAvailable, material, resources, shipMass float64
+ wantShips uint
+ wantMaterialLeft, wantProductionUsed, wantProgress float64
+ }{
+ {
+ name: "ample material: ten ships, no farming",
+ productionAvailable: 100, material: 100, resources: 10, shipMass: 1,
+ wantShips: 10, wantMaterialLeft: 90, wantProductionUsed: 0, wantProgress: 0,
+ },
+ {
+ name: "no material: partial progress on a farmed ship",
+ productionAvailable: 114, material: 0, resources: 0.5, shipMass: 10,
+ // ShipBuildCost(10,0,0.5) = 100 + 10/0.5 = 120; 114/120 = 0.95.
+ wantShips: 0, wantMaterialLeft: 0, wantProductionUsed: 114, wantProgress: 0.95,
+ },
+ {
+ name: "no production available leaves the stockpile",
+ productionAvailable: 0, material: 50, resources: 10, shipMass: 5,
+ wantShips: 0, wantMaterialLeft: 50, wantProductionUsed: 0, wantProgress: 0,
+ },
+ {
+ name: "zero ship mass is guarded against an endless loop",
+ productionAvailable: 100, material: 50, resources: 10, shipMass: 0,
+ wantShips: 0, wantMaterialLeft: 50, wantProductionUsed: 0, wantProgress: 0,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn(
+ tc.productionAvailable, tc.material, tc.resources, tc.shipMass)
+ if ships != tc.wantShips {
+ t.Errorf("ships = %d, want %d", ships, tc.wantShips)
+ }
+ if math.Abs(materialLeft-tc.wantMaterialLeft) > 1e-9 {
+ t.Errorf("materialLeft = %v, want %v", materialLeft, tc.wantMaterialLeft)
+ }
+ if math.Abs(productionUsed-tc.wantProductionUsed) > 1e-9 {
+ t.Errorf("productionUsed = %v, want %v", productionUsed, tc.wantProductionUsed)
+ }
+ if math.Abs(progress-tc.wantProgress) > 1e-9 {
+ t.Errorf("progress = %v, want %v", progress, tc.wantProgress)
+ }
+ })
+ }
+}
diff --git a/pkg/calc/ship.go b/pkg/calc/ship.go
index b0919f3..7a29a8c 100644
--- a/pkg/calc/ship.go
+++ b/pkg/calc/ship.go
@@ -94,3 +94,14 @@ func EffectiveDefence(
}
return defendingShields * defendingShiledsTech / math.Pow(defendingFullMass, 1./3.) * math.Pow(30., 1./3.)
}
+
+// BombingPower returns the bombing power of number ships whose weapons
+// block is weapons, built at weapons tech level weaponsTech and carrying
+// armament weapon mounts. The leading factor sqrt(weapons*weaponsTech)/10
+// + 1 makes the power grow super-linearly with effective weapon strength,
+// which then scales linearly with weapons, weaponsTech, armament, and
+// number. With zero armament or zero weapons the power is zero.
+func BombingPower(weapons, weaponsTech, armament, number float64) float64 {
+ return (math.Sqrt(weapons*weaponsTech)/10. + 1.) *
+ weapons * weaponsTech * armament * number
+}
diff --git a/pkg/calc/ship_test.go b/pkg/calc/ship_test.go
index 09b0ba1..7abd5c7 100644
--- a/pkg/calc/ship_test.go
+++ b/pkg/calc/ship_test.go
@@ -32,3 +32,27 @@ func TestBlockUpgradeCost(t *testing.T) {
})
}
}
+
+func TestBombingPower(t *testing.T) {
+ cases := []struct {
+ name string
+ weapons, weaponsTech, armament, number float64
+ want float64
+ }{
+ // Parity with the engine's Battle_Station fixture
+ // (game/internal/model/game/group_test.go): (sqrt(30)/10+1)*30*3.
+ {"battle station, single ship", 30, 1, 3, 1, 139.29503},
+ {"battle station, two ships scale linearly", 30, 1, 3, 2, 278.59006},
+ {"no armament: zero power", 30, 1, 0, 5, 0},
+ {"no weapons: zero power", 0, 1, 3, 5, 0},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := calc.BombingPower(tc.weapons, tc.weaponsTech, tc.armament, tc.number)
+ if math.Abs(got-tc.want) > 1e-3 {
+ t.Errorf("BombingPower(%v, %v, %v, %v) = %v, want %v",
+ tc.weapons, tc.weaponsTech, tc.armament, tc.number, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/pkg/calc/solve.go b/pkg/calc/solve.go
new file mode 100644
index 0000000..44b5f57
--- /dev/null
+++ b/pkg/calc/solve.go
@@ -0,0 +1,86 @@
+package calc
+
+// This file holds the inverse ("goal-seek") counterparts of the forward
+// ship formulas. The ship-class calculator lets a player pin one derived
+// result and back-solve the single input it claims; each solver inverts
+// exactly one forward function so the math stays in this package rather
+// than leaking into the UI bridge. Every solver reports ok == false when
+// the request is infeasible (e.g. an unreachable target or a division by
+// a non-positive tech level), leaving the returned value undefined.
+
+// WeaponsForAttack returns the weapons block that yields targetAttack at
+// weapons tech level weaponsTech, inverting [EffectiveAttack]. It is
+// infeasible when weaponsTech is non-positive or targetAttack is
+// negative.
+func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) {
+ if weaponsTech <= 0 || targetAttack < 0 {
+ return 0, false
+ }
+ return targetAttack / weaponsTech, true
+}
+
+// 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.
+func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) {
+ ceiling := 20 * driveTech
+ if driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling {
+ return 0, false
+ }
+ return targetSpeed * restMass / (ceiling - targetSpeed), true
+}
+
+// ShieldsForDefence returns the shields block that yields targetDefence
+// for a ship whose mass excluding the shields block is restMass, at
+// shields tech level shieldsTech, inverting [EffectiveDefence]. Defence
+// rises monotonically with shields (the block adds mass to its own
+// denominator), so the block is found by bisection. It is infeasible when
+// targetDefence or shieldsTech is non-positive.
+func ShieldsForDefence(targetDefence, shieldsTech, restMass float64) (float64, bool) {
+ if targetDefence <= 0 || shieldsTech <= 0 {
+ return 0, false
+ }
+ lo, hi := 0.0, 1.0
+ for EffectiveDefence(hi, shieldsTech, hi+restMass) < targetDefence {
+ hi *= 2
+ if hi > 1e12 {
+ return 0, false
+ }
+ }
+ for range 100 {
+ mid := (lo + hi) / 2
+ if EffectiveDefence(mid, shieldsTech, mid+restMass) < targetDefence {
+ lo = mid
+ } else {
+ hi = mid
+ }
+ }
+ return (lo + hi) / 2, true
+}
+
+// CargoForEmptyMass returns the cargo block that brings a ship's empty
+// mass to targetEmptyMass, given restMass — the combined mass of the
+// other blocks (drive, shields, and the weapons block) — inverting the
+// cargo term of [EmptyMass]. It is infeasible when targetEmptyMass is
+// below restMass, which would require a negative cargo block.
+func CargoForEmptyMass(targetEmptyMass, restMass float64) (float64, bool) {
+ cargo := targetEmptyMass - restMass
+ if cargo < 0 {
+ return 0, false
+ }
+ return cargo, true
+}
+
+// LoadForFullMass returns the cargo load that brings a ship's full mass
+// to targetFullMass, given its empty mass and cargo tech level, inverting
+// [CarryingMass] inside [FullMass]. It is infeasible when targetFullMass
+// is below emptyMass or cargoTech is non-positive.
+func LoadForFullMass(targetFullMass, emptyMass, cargoTech float64) (float64, bool) {
+ if cargoTech <= 0 || targetFullMass < emptyMass {
+ return 0, false
+ }
+ return (targetFullMass - emptyMass) * cargoTech, true
+}
diff --git a/pkg/calc/solve_test.go b/pkg/calc/solve_test.go
new file mode 100644
index 0000000..a032fce
--- /dev/null
+++ b/pkg/calc/solve_test.go
@@ -0,0 +1,66 @@
+package calc_test
+
+import (
+ "math"
+ "testing"
+
+ "galaxy/calc"
+)
+
+func TestWeaponsForAttack(t *testing.T) {
+ got, ok := calc.WeaponsForAttack(calc.EffectiveAttack(12, 1.5), 1.5)
+ if !ok || math.Abs(got-12) > 1e-9 {
+ t.Errorf("WeaponsForAttack round-trip = %v (ok=%v), want 12", got, ok)
+ }
+ if _, ok := calc.WeaponsForAttack(10, 0); ok {
+ t.Error("WeaponsForAttack with zero tech should be infeasible")
+ }
+}
+
+func TestDriveForSpeed(t *testing.T) {
+ const drive, driveTech, restMass = 10.0, 1.2, 35.0
+ speed := calc.Speed(calc.DriveEffective(drive, driveTech), drive+restMass)
+ got, ok := calc.DriveForSpeed(speed, driveTech, restMass)
+ 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.
+ if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok {
+ t.Error("DriveForSpeed at the speed ceiling 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)
+ got, ok := calc.ShieldsForDefence(defence, shieldsTech, restMass)
+ if !ok || math.Abs(got-shields) > 1e-6 {
+ t.Errorf("ShieldsForDefence round-trip = %v (ok=%v), want %v", got, ok, shields)
+ }
+ if _, ok := calc.ShieldsForDefence(0, shieldsTech, restMass); ok {
+ t.Error("ShieldsForDefence at a zero target should be infeasible")
+ }
+}
+
+func TestCargoForEmptyMass(t *testing.T) {
+ const restMass, cargo = 30.0, 12.0
+ got, ok := calc.CargoForEmptyMass(restMass+cargo, restMass)
+ if !ok || math.Abs(got-cargo) > 1e-9 {
+ t.Errorf("CargoForEmptyMass round-trip = %v (ok=%v), want %v", got, ok, cargo)
+ }
+ if _, ok := calc.CargoForEmptyMass(restMass-1, restMass); ok {
+ t.Error("CargoForEmptyMass below the fixed block mass should be infeasible")
+ }
+}
+
+func TestLoadForFullMass(t *testing.T) {
+ const emptyMass, cargoTech, load = 45.0, 1.0, 20.0
+ full := calc.FullMass(emptyMass, calc.CarryingMass(load, cargoTech))
+ got, ok := calc.LoadForFullMass(full, emptyMass, cargoTech)
+ if !ok || math.Abs(got-load) > 1e-9 {
+ t.Errorf("LoadForFullMass round-trip = %v (ok=%v), want %v", got, ok, load)
+ }
+ if _, ok := calc.LoadForFullMass(emptyMass-1, emptyMass, cargoTech); ok {
+ t.Error("LoadForFullMass below empty mass should be infeasible")
+ }
+}
diff --git a/ui/PLAN.md b/ui/PLAN.md
index c988b25..0d975f3 100644
--- a/ui/PLAN.md
+++ b/ui/PLAN.md
@@ -1968,7 +1968,11 @@ Decisions baked into Phase 16 (vs. the original stage description):
## ~~Phase 17. Ship Classes — CRUD Without Calc~~
-Status: done (local-ci run 20).
+Status: done (local-ci run 20). Note: Phase 30 removed the standalone
+designer view/route described below and folded ship-class create/view
+into the sidebar calculator; the table's row-activate and "new" button
+now open the calculator. The table itself and the validator are
+unchanged.
Goal: list, view, create, and delete ship classes through a
dedicated table view and a designer view; numeric calculations are
@@ -2037,7 +2041,10 @@ Targeted tests:
## ~~Phase 18. Ship Classes — Calc Bridge~~
-Status: done.
+Status: done. Note: the live mass/speed/range preview built here moved
+into the Phase 30 calculator when the standalone designer was removed;
+the `ui/core/calc` ship bridge wrapped here is reused unchanged and
+extended by Phase 30 (combat, planet build, solvers).
Goal: wire `pkg/calc/` ship math into the designer for live mass,
speed, range, and cargo capacity previews.
@@ -3314,39 +3321,93 @@ Decisions:
rendered result is verified by a high-contrast screenshot during
development plus the existing fog / render-on-demand e2e.
-## Phase 30. Calculator Tab
+## ~~Phase 30. Ship Class Calculator~~
-Status: pending.
+Status: done (gitea `go-unit` + `ui-test` green; deployed to dev). UI
+polish deferred to a later pass; the core functionality is in place.
-Goal: ship an independent calculator in the sidebar, callable from any
-view, exposing the full set of `pkg/calc/` functions wired through
-`Core`.
+Goal: replace the standalone Phase 17/18 ship-class designer with a
+fused designer + calculator living in the sidebar. It shows the ship
+design blocks, live derived results (mass, speed, attack, defence,
+bombing), and a planet build-rate readout, and adds single-target
+goal-seek — the player pins one result and the model back-solves the
+single input it claims. A second mode reuses the design area to price
+ship-class modernization. The standalone designer view and route are
+removed; the ship-classes table and the view/bottom menus open the
+calculator instead.
+
+The original four detached modes (ship / path / modernization /
+bombing) were dropped during planning: path is deferred (MVP path is
+brute force) and replaced by auto reach circles on the map; bombing is
+folded in as a per-ship result; ship and modernization are the two
+modes. See `ui/CALCULATOR.md` history (removed) and the interview
+decisions baked below.
+
+Goal-seek claim map (one lock at a time): attack → weapons,
+defence → shields, empty/loaded speed → drive, empty mass → cargo,
+loaded mass → cargo load. Locking one result disables the others'
+lock affordances; an unreachable target shows the locked cell in an
+error state. Tech levels and the planet MAT are override inputs with a
+reset lock; the player tech is the default.
Artifacts:
-- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` UI with mode
- selector (ship calculator, path calculator, modernization cost,
- bombing power) and per-mode forms
-- bridge entries in `ui/core/calc/` for any function not already
- wrapped by Phase 18
-- topic doc `ui/docs/calculator-ux.md` documenting modes,
- layouts, and the rule that calculator inputs persist across
- navigation
+- `pkg/calc/` additions, single-sourced (no mirroring): `BombingPower`
+ extracted from `game/internal/model/game/group.go`; a pure
+ `ProduceShipsInTurn` extracted from `controller.ProduceShip` (the
+ engine now delegates to both); inverse solvers in `pkg/calc/solve.go`
+ (`WeaponsForAttack`, `DriveForSpeed`, `ShieldsForDefence` by
+ bisection, `CargoForEmptyMass`, `LoadForFullMass`)
+- thin bridges in `ui/core/calc/` (combat, planet build, solvers),
+ registered in `ui/wasm/main.go`, typed on `Core`
+ (`platform/core/index.ts` + `wasm.ts`)
+- `ui/frontend/src/lib/calculator/calc-model.ts` pure orchestration
+ (forward results + single-target goal-seek + planet build)
+- `ui/frontend/src/lib/calculator/ship-design-area.svelte` reusable
+ design block (5 blocks + 4 techs, override locks, computed-block
+ read-only) — earmarked for the future ship-group upgrade flow
+- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` shell (mode
+ selector, name combobox + Create / Delete, the calc and planet areas
+ inline)
+- `ui/frontend/src/map/reach-circles.ts` + `lib/calculator/reach.svelte.ts`
+ shared store: the calculator publishes the selected planet origin and
+ loaded speed, the map draws 1–3 reach circles
+- `lib/calculator/load-request.svelte.ts` shared store: the table /
+ menus ask the layout to open the calculator on a class
+- topic doc `ui/docs/calculator-ux.md`; `ui/docs/calc-bridge.md`
+ extended with the new wired functions
-Dependencies: Phase 18.
+Dependencies: Phases 17, 18, 19/20 (selection store), 29 (map modes).
Acceptance criteria:
-- every calculator mode produces results identical to direct
- `pkg/calc/` calls;
-- inputs persist across view switches per global state-preservation
- rule;
-- calculator works in history mode against the snapshot's tech levels.
+- every result is byte-identical to direct `pkg/calc/` calls on shared
+ fixtures (Go parity tests);
+- locking one result back-solves its claimed input; a second lock is
+ disabled; an unreachable target shows the error state;
+- Create reuses the existing ship-class command flow and validator;
+ selecting an existing class loads it as a template;
+- inputs persist across view switches per the global state-preservation
+ rule; the calculator works in history mode against the snapshot's
+ tech levels;
+- selecting an own planet draws the reach circles; clearing the
+ selection or an invalid design removes them;
+- the standalone designer view/route no longer exists.
Targeted tests:
-- Vitest snapshot tests per mode on canonical inputs;
-- Playwright e2e: switch modes, confirm input persistence.
+- Go: `pkg/calc` unit tests + engine parity (`ProduceShip`,
+ `BombingPower`); `ui/core/calc` bridge parity; solver round-trips;
+- Vitest: `calc-model` (forward, goal-seek per claim, infeasible),
+ `calculator-tab` (results, goal-seek, Create, planet area),
+ `reach-circles` math;
+- Playwright e2e: create / list / delete via the table + calculator,
+ Create-disabled-while-invalid (`tests/e2e/ship-classes.spec.ts`).
+
+Note: the WASM artefact `ui/frontend/static/core.wasm` must be rebuilt
+(`make wasm`, needs TinyGo) for the new bridge functions to be present
+at runtime and in the Playwright suite; Vitest injects a fake `Core`
+and does not need the rebuild.
## Phase 31. Wails Desktop Wrapper
@@ -3483,54 +3544,56 @@ Targeted tests:
- regression test: bumping the app version invalidates the prior
service worker.
-## Phase 34. Multi-Turn Projection — Single-Turn Forecast and Range Circles
+## Phase 34. Multi-Turn Projection — Realistic Planet Forecast
Status: pending. Long-term scope deferred but this phase ships real
features.
-Goal: ship two concrete projection features (planet next-turn
-forecast and ship-designer reach circles) plus the transient
-map-overlay back-stack mechanism that the reach-circles feature is
-the first user of.
+Goal: ship a realistic multi-turn planet projection and surface it in
+the planet inspector and in the calculator's planet area. Reach circles
+already shipped in Phase 30 (auto-drawn from the calculator's selected
+planet); this phase no longer owns them.
+
+The Phase 30 planet area is single-turn (MAT-only): it answers "ships
+this turn / turns per ship" at the current or overridden MAT. This phase
+makes it realistic and multi-turn by extracting the planet economy into
+`pkg/calc` and simulating turns: population growth (`×1.08`), material /
+capital / colonist supply, and the capital/colonist unpacking that
+mirrors `MakeTurn` steps 09/12/14/15. CAP and COL only affect future
+turns (post-production unpacking), so they become meaningful here and
+are added to the calculator's planet area as supply inputs alongside
+MAT.
Artifacts:
-- `ui/frontend/src/lib/projection/` minimal projection engine that
- computes one-turn-ahead state for a single planet using `pkg/calc/`
-- planet inspector forecast section showing next-turn population,
- industry, materials stockpile, and production progress
-- `ui/frontend/src/lib/navigation/transient-overlay.ts` push/pop
- back-stack mechanism for map overlays driven by other views, with
- a back-button affordance on the map that returns to the originating
- view with state preserved
-- ship-designer `Preview range on map` action that pushes a transient
- overlay onto the map showing concentric reach circles for 1, 2, 3,
- 4 turns from a chosen origin, computed from the in-progress ship
- design and the player's current Drive tech via `ui/core/calc/`
-- topic doc `ui/docs/multi-turn-projection.md` describing the
- long-term vision (multi-turn planning mode, scenario branches) and
- the phased path to it
+- `pkg/calc/` planet-economy extraction (single-sourced, engine
+ delegates): `PlanetProduction`, `ProducePopulation`,
+ `UnpackColonists`, `UnpackCapital`, reusing `ProduceShipsInTurn`; a
+ multi-turn projector `ProjectPlanetBuild` answering "K ships in M
+ turns" under guaranteed per-turn supply
+- thin bridges in `ui/core/calc/` + `Core` typings
+- planet inspector forecast section (next-turn population, industry,
+ materials, production progress)
+- calculator planet area gains CAP and COL supply inputs and switches
+ its readout to the multi-turn projector
+- topic doc `ui/docs/multi-turn-projection.md` (long-term vision:
+ multi-turn planning mode, scenario branches)
-Dependencies: Phases 17, 18.
+Dependencies: Phases 17, 18, 30.
Acceptance criteria:
-- the planet inspector shows a forecast section with next-turn values
- matching `pkg/calc/` outputs;
-- the ship-designer `Preview range on map` button transitions to the
- map with reach circles drawn from the chosen origin; back returns
- to the designer with all in-progress state intact;
-- the transient overlay is cleared if the user navigates to any other
- view via the header dropdown.
+- projector output is byte-identical to running the engine's per-turn
+ planet update over the same turns (Go parity);
+- the planet inspector shows a forecast section matching it;
+- the calculator planet area honours MAT / CAP / COL supply and shows
+ "K ships in M turns" consistent with the projector.
Targeted tests:
-- Vitest unit tests for the projection engine on canonical fixtures;
-- Vitest unit tests for the transient-overlay push/pop logic and
- state preservation;
-- Playwright e2e: open a planet inspector, observe one-turn forecast;
- open a ship designer, click `Preview range on map`, see reach
- circles, click back, return with state intact.
+- Go parity tests for each extracted economy formula and the projector;
+- Vitest for the calculator planet area with supply inputs;
+- Playwright e2e: planet inspector forecast section.
## Phase 35. Polish — Accessibility, Localisation, Error UX
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/core/calc/planet.go b/ui/core/calc/planet.go
new file mode 100644
index 0000000..c236381
--- /dev/null
+++ b/ui/core/calc/planet.go
@@ -0,0 +1,22 @@
+package calc
+
+import "galaxy/calc"
+
+// ShipBuildCost wraps `calc.ShipBuildCost` (`pkg/calc/planet.go`): the
+// per-turn production cost of one ship of empty mass shipMass on a planet
+// holding the given material stockpile at the given resources rating.
+func ShipBuildCost(shipMass, material, resources float64) float64 {
+ return calc.ShipBuildCost(shipMass, material, resources)
+}
+
+// ProduceShipsInTurn wraps `calc.ProduceShipsInTurn`
+// (`pkg/calc/planet.go`): one turn of ship production. It returns the
+// whole ships completed this turn, the material left afterwards, the
+// production units spent on the next (incomplete) ship, and that ship's
+// progress fraction. The calculator's planet area renders ships-per-turn
+// and turns-per-ship from this single call so it agrees with the engine.
+func ProduceShipsInTurn(
+ productionAvailable, material, resources, shipMass float64,
+) (uint, float64, float64, float64) {
+ return calc.ProduceShipsInTurn(productionAvailable, material, resources, shipMass)
+}
diff --git a/ui/core/calc/planet_test.go b/ui/core/calc/planet_test.go
new file mode 100644
index 0000000..d43f212
--- /dev/null
+++ b/ui/core/calc/planet_test.go
@@ -0,0 +1,57 @@
+package calc_test
+
+import (
+ "testing"
+
+ source "galaxy/calc"
+ bridge "galaxy/core/calc"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestShipBuildCostParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ shipMass, material, resources float64
+ }{
+ {"material_covers_mass", 5, 10, 0.5},
+ {"material_short", 10, 3, 0.5},
+ {"no_material", 4, 0, 0.5},
+ {"zero_resources_guard", 10, 3, 0},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ want := source.ShipBuildCost(c.shipMass, c.material, c.resources)
+ got := bridge.ShipBuildCost(c.shipMass, c.material, c.resources)
+ assert.Equal(t, want, got)
+ })
+ }
+}
+
+func TestProduceShipsInTurnParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ productionAvailable, material, resources, shipMass float64
+ }{
+ {"ample_material", 100, 100, 10, 1},
+ {"farmed_partial", 114, 0, 0.5, 10},
+ {"no_production", 0, 50, 10, 5},
+ {"zero_ship_mass", 100, 50, 10, 0},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ wantShips, wantMat, wantUsed, wantProg := source.ProduceShipsInTurn(
+ c.productionAvailable, c.material, c.resources, c.shipMass)
+ gotShips, gotMat, gotUsed, gotProg := bridge.ProduceShipsInTurn(
+ c.productionAvailable, c.material, c.resources, c.shipMass)
+ assert.Equal(t, wantShips, gotShips)
+ assert.Equal(t, wantMat, gotMat)
+ assert.Equal(t, wantUsed, gotUsed)
+ assert.Equal(t, wantProg, gotProg)
+ })
+ }
+}
diff --git a/ui/core/calc/ship.go b/ui/core/calc/ship.go
index 1a446c9..33e1173 100644
--- a/ui/core/calc/ship.go
+++ b/ui/core/calc/ship.go
@@ -65,3 +65,26 @@ func CarryingMass(load, cargoTech float64) float64 {
func BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech float64) float64 {
return calc.BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech)
}
+
+// EffectiveAttack wraps `calc.EffectiveAttack` (`pkg/calc/ship.go`):
+// combat attack power of a ship, equal to its weapons block times the
+// player's weapons tech.
+func EffectiveAttack(weapons, weaponsTech float64) float64 {
+ return calc.EffectiveAttack(weapons, weaponsTech)
+}
+
+// EffectiveDefence wraps `calc.EffectiveDefence` (`pkg/calc/ship.go`):
+// combat defence power of a ship, its shields block times the player's
+// shields tech, normalised by the cube root of full mass (bigger hulls
+// defend worse per shield point). Zero when defendingFullMass ≤ 0.
+func EffectiveDefence(defendingShields, defendingShieldsTech, defendingFullMass float64) float64 {
+ return calc.EffectiveDefence(defendingShields, defendingShieldsTech, defendingFullMass)
+}
+
+// BombingPower wraps `calc.BombingPower` (`pkg/calc/ship.go`): the
+// planet-bombing power of number ships with the given weapons block,
+// weapons tech, and armament. The calculator passes number = 1 for a
+// per-ship reading.
+func BombingPower(weapons, weaponsTech, armament, number float64) float64 {
+ return calc.BombingPower(weapons, weaponsTech, armament, number)
+}
diff --git a/ui/core/calc/ship_test.go b/ui/core/calc/ship_test.go
index 529802f..c9557a0 100644
--- a/ui/core/calc/ship_test.go
+++ b/ui/core/calc/ship_test.go
@@ -28,28 +28,28 @@ type shipFixture struct {
func fixtures() []shipFixture {
return []shipFixture{
{
- name: "all_zero",
- drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0,
+ name: "all_zero",
+ drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0,
driveTech: 1, cargoTech: 1,
},
{
- name: "typical_mid_tech",
- drive: 8, armament: 2, weapons: 5, shields: 3, cargo: 4,
+ name: "typical_mid_tech",
+ drive: 8, armament: 2, weapons: 5, shields: 3, cargo: 4,
driveTech: 1.5, cargoTech: 1.2,
},
{
- name: "heavy_armoured",
- drive: 3, armament: 5, weapons: 12, shields: 20, cargo: 1,
+ name: "heavy_armoured",
+ drive: 3, armament: 5, weapons: 12, shields: 20, cargo: 1,
driveTech: 0.8, cargoTech: 0.5,
},
{
- name: "invalid_weapons_no_armament",
- drive: 5, armament: 0, weapons: 4, shields: 1, cargo: 2,
+ name: "invalid_weapons_no_armament",
+ drive: 5, armament: 0, weapons: 4, shields: 1, cargo: 2,
driveTech: 1, cargoTech: 1,
},
{
- name: "invalid_armament_no_weapons",
- drive: 5, armament: 3, weapons: 0, shields: 1, cargo: 2,
+ name: "invalid_armament_no_weapons",
+ drive: 5, armament: 3, weapons: 0, shields: 1, cargo: 2,
driveTech: 1, cargoTech: 1,
},
}
@@ -236,3 +236,66 @@ func TestDesignerPreviewComposition(t *testing.T) {
assert.Equal(t, wantMaxSpeed, maxSpeed)
assert.Equal(t, wantRange, rangePerTurn)
}
+
+func TestEffectiveAttackParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ weapons float64
+ weaponsTech float64
+ }{
+ {"zero", 0, 1},
+ {"typical", 15, 1.5},
+ {"high_tech", 8, 3.2},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ want := source.EffectiveAttack(c.weapons, c.weaponsTech)
+ got := bridge.EffectiveAttack(c.weapons, c.weaponsTech)
+ assert.Equal(t, want, got)
+ })
+ }
+}
+
+func TestEffectiveDefenceParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ shields float64
+ shieldsTech float64
+ fullMass float64
+ }{
+ {"zero_mass_returns_zero", 10, 1, 0},
+ {"typical", 20, 1.2, 45},
+ {"heavy_hull", 100, 1, 600},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ want := source.EffectiveDefence(c.shields, c.shieldsTech, c.fullMass)
+ got := bridge.EffectiveDefence(c.shields, c.shieldsTech, c.fullMass)
+ assert.Equal(t, want, got)
+ })
+ }
+}
+
+func TestBombingPowerParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ weapons, weaponsTech, armament, number float64
+ }{
+ {"no_armament", 30, 1, 0, 1},
+ {"battle_station", 30, 1, 3, 1},
+ {"fleet", 30, 1.5, 3, 4},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ want := source.BombingPower(c.weapons, c.weaponsTech, c.armament, c.number)
+ got := bridge.BombingPower(c.weapons, c.weaponsTech, c.armament, c.number)
+ assert.Equal(t, want, got)
+ })
+ }
+}
diff --git a/ui/core/calc/solve.go b/ui/core/calc/solve.go
new file mode 100644
index 0000000..580ce30
--- /dev/null
+++ b/ui/core/calc/solve.go
@@ -0,0 +1,42 @@
+package calc
+
+import "galaxy/calc"
+
+// This file bridges the inverse ("goal-seek") solvers from
+// `pkg/calc/solve.go`. The ship-class calculator lets a player pin one
+// derived result and back-solve the single input it claims; each wrapper
+// is a one-line passthrough so the inverse math stays in `pkg/calc`. Every
+// solver returns ok == false when the request is infeasible.
+
+// WeaponsForAttack wraps `calc.WeaponsForAttack`: the weapons block that
+// yields targetAttack at weapons tech weaponsTech.
+func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) {
+ return calc.WeaponsForAttack(targetAttack, weaponsTech)
+}
+
+// DriveForSpeed wraps `calc.DriveForSpeed`: the drive block that yields
+// targetSpeed for a ship whose mass excluding the drive block is restMass,
+// at drive tech driveTech.
+func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) {
+ return calc.DriveForSpeed(targetSpeed, driveTech, restMass)
+}
+
+// ShieldsForDefence wraps `calc.ShieldsForDefence`: the shields block that
+// yields targetDefence for a ship whose mass excluding the shields block
+// is restMass, at shields tech shieldsTech.
+func ShieldsForDefence(targetDefence, shieldsTech, restMass float64) (float64, bool) {
+ return calc.ShieldsForDefence(targetDefence, shieldsTech, restMass)
+}
+
+// CargoForEmptyMass wraps `calc.CargoForEmptyMass`: the cargo block that
+// brings empty mass to targetEmptyMass given restMass, the mass of the
+// other blocks.
+func CargoForEmptyMass(targetEmptyMass, restMass float64) (float64, bool) {
+ return calc.CargoForEmptyMass(targetEmptyMass, restMass)
+}
+
+// LoadForFullMass wraps `calc.LoadForFullMass`: the cargo load that brings
+// full mass to targetFullMass given the ship's empty mass and cargo tech.
+func LoadForFullMass(targetFullMass, emptyMass, cargoTech float64) (float64, bool) {
+ return calc.LoadForFullMass(targetFullMass, emptyMass, cargoTech)
+}
diff --git a/ui/core/calc/solve_test.go b/ui/core/calc/solve_test.go
new file mode 100644
index 0000000..d6d6b47
--- /dev/null
+++ b/ui/core/calc/solve_test.go
@@ -0,0 +1,75 @@
+package calc_test
+
+import (
+ "testing"
+
+ source "galaxy/calc"
+ bridge "galaxy/core/calc"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestWeaponsForAttackParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct{ targetAttack, weaponsTech float64 }{
+ {18, 1.5}, {0, 1}, {10, 0},
+ }
+ for _, c := range cases {
+ wantV, wantOk := source.WeaponsForAttack(c.targetAttack, c.weaponsTech)
+ gotV, gotOk := bridge.WeaponsForAttack(c.targetAttack, c.weaponsTech)
+ assert.Equal(t, wantOk, gotOk)
+ assert.Equal(t, wantV, gotV)
+ }
+}
+
+func TestDriveForSpeedParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct{ targetSpeed, driveTech, restMass float64 }{
+ {5, 1.2, 35}, {24, 1.2, 35}, {0, 1, 10},
+ }
+ for _, c := range cases {
+ wantV, wantOk := source.DriveForSpeed(c.targetSpeed, c.driveTech, c.restMass)
+ gotV, gotOk := bridge.DriveForSpeed(c.targetSpeed, c.driveTech, c.restMass)
+ assert.Equal(t, wantOk, gotOk)
+ assert.Equal(t, wantV, gotV)
+ }
+}
+
+func TestShieldsForDefenceParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct{ targetDefence, shieldsTech, restMass float64 }{
+ {5, 1, 40}, {0, 1, 40}, {3, 0, 40},
+ }
+ for _, c := range cases {
+ wantV, wantOk := source.ShieldsForDefence(c.targetDefence, c.shieldsTech, c.restMass)
+ gotV, gotOk := bridge.ShieldsForDefence(c.targetDefence, c.shieldsTech, c.restMass)
+ assert.Equal(t, wantOk, gotOk)
+ assert.Equal(t, wantV, gotV)
+ }
+}
+
+func TestCargoForEmptyMassParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct{ targetEmptyMass, restMass float64 }{
+ {42, 30}, {29, 30},
+ }
+ for _, c := range cases {
+ wantV, wantOk := source.CargoForEmptyMass(c.targetEmptyMass, c.restMass)
+ gotV, gotOk := bridge.CargoForEmptyMass(c.targetEmptyMass, c.restMass)
+ assert.Equal(t, wantOk, gotOk)
+ assert.Equal(t, wantV, gotV)
+ }
+}
+
+func TestLoadForFullMassParity(t *testing.T) {
+ t.Parallel()
+ cases := []struct{ targetFullMass, emptyMass, cargoTech float64 }{
+ {65, 45, 1}, {44, 45, 1}, {65, 45, 0},
+ }
+ for _, c := range cases {
+ wantV, wantOk := source.LoadForFullMass(c.targetFullMass, c.emptyMass, c.cargoTech)
+ gotV, gotOk := bridge.LoadForFullMass(c.targetFullMass, c.emptyMass, c.cargoTech)
+ assert.Equal(t, wantOk, gotOk)
+ assert.Equal(t, wantV, gotV)
+ }
+}
diff --git a/ui/docs/calc-bridge.md b/ui/docs/calc-bridge.md
index 6d88ebb..8a036eb 100644
--- a/ui/docs/calc-bridge.md
+++ b/ui/docs/calc-bridge.md
@@ -11,11 +11,14 @@ matching TS adapter in `ui/frontend/src/platform/core/`.
Phase 18 lands the **ship-math slice** of the bridge — everything
the ship-class designer needs to render its preview pane. Phase 20
extends it with `BlockUpgradeCost` so the ship-group inspector can
-preview modernize cost. Other slices (production forecast, science
-research, ship build progress) remain deferred to dedicated future
-phases. This document is the running audit trail of what is live,
-what is missing, and how each function maps to its `pkg/calc/`
-source.
+preview modernize cost. Phase 30 extends it with the **combat,
+planet-build, and goal-seek slice** for the ship-class calculator:
+`EffectiveAttack`, `EffectiveDefence`, `BombingPower`, `ShipBuildCost`,
+`ProduceShipsInTurn`, and the inverse solvers from `pkg/calc/solve.go`.
+Other slices (production/science forecast, the realistic multi-turn
+planet projection) remain deferred to dedicated future phases. This
+document is the running audit trail of what is live, what is missing,
+and how each function maps to its `pkg/calc/` source.
## Live bridge surface
@@ -35,7 +38,28 @@ on the JS-side `globalThis.galaxyCore` (registered in
| `speed` | `calc.Speed(driveEffective, fullMass)` | `number` | designer preview (speed + range) |
| `cargoCapacity` | `calc.CargoCapacity(cargo, cargoTech)` | `number` | designer preview (cargo row) |
| `carryingMass` | `calc.CarryingMass(load, cargoTech)` | `number` | designer preview (full-load mass) |
-| `blockUpgradeCost` | `calc.BlockUpgradeCost(blockMass, currentTech, target)` | `number` | ship-group inspector modernize preview |
+| `blockUpgradeCost` | `calc.BlockUpgradeCost(blockMass, currentTech, target)` | `number` | ship-group inspector + modernization mode |
+| `effectiveAttack` | `calc.EffectiveAttack(weapons, weaponsTech)` | `number` | calculator (attack result) |
+| `effectiveDefence` | `calc.EffectiveDefence(shields, shieldsTech, fullMass)` | `number` | calculator (defence result) |
+| `bombingPower` | `calc.BombingPower(weapons, weaponsTech, armament, n)` | `number` | calculator (bombing result, n = 1) |
+| `shipBuildCost` | `calc.ShipBuildCost(shipMass, material, resources)` | `number` | calculator (planet build) |
+| `produceShipsInTurn`| `calc.ProduceShipsInTurn(L, material, resources, mass)` | `{ships,…}` | calculator (planet ships/turn) |
+| `weaponsForAttack` | `calc.WeaponsForAttack(targetAttack, weaponsTech)` | `number\|null` | calculator goal-seek (attack → weapons) |
+| `driveForSpeed` | `calc.DriveForSpeed(targetSpeed, driveTech, restMass)` | `number\|null` | calculator goal-seek (speed → drive) |
+| `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
+`game/internal/model/game/group.go` and the per-iteration build math
+from `controller.ProduceShip` into `pkg/calc` (`ProduceShipsInTurn`),
+and the engine now delegates to both — a true refactor, not a mirror.
+The inverse solvers (`pkg/calc/solve.go`) invert the forward formulas
+for single-target goal-seek and return `null` when infeasible;
+`shieldsForDefence` uses bisection, the rest are analytic. Parity and
+round-trip tests live in `ui/core/calc/{ship,planet,solve}_test.go`.
`number|null` returns mirror the Go `(float64, bool)` signature: the
upstream validator rejects weapons/armament pairings with one zero
@@ -85,12 +109,14 @@ whether the underlying Go function exists.
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | :-------------: | :-------------: |
| Ship-class designer preview (Phase 18) | `EmptyMass`, `FullMass`, `Speed`, `DriveEffective`, `CargoCapacity`, `CarryingMass`, `WeaponsBlockMass` (`pkg/calc/ship.go`) | yes | yes |
| Ship-group modernize cost preview (Phase 20) | `BlockUpgradeCost` (`pkg/calc/ship.go`, migrated from `game/internal/controller/ship_group_upgrade.go`) | yes | yes |
+| Ship calculator combat (Phase 30) | `EffectiveAttack`, `EffectiveDefence`, `BombingPower` (`pkg/calc/ship.go`; `BombingPower` extracted from `model/game/group.go`) | yes | yes |
+| Ship calculator goal-seek (Phase 30) | inverse solvers in `pkg/calc/solve.go` | yes | yes |
| Free production potential (`freeIndustry`) | `Planet.ProductionCapacity` → `industry*0.75 + population*0.25` (`game/internal/model/game/planet.go`) | no | no |
| Industry production output per turn | `Planet.ProduceIndustry(freeProduction)` (`planet.go`); `freeProduction/5` modulo material constraint | no | no |
| Materials production output per turn | `Planet.ProduceMaterial(freeProduction)` (`planet.go`); `freeProduction * resources` | no | no |
| Per-tech research progress (DRIVE/WEAPONS/…) | `ResearchTech` (`game/internal/model/game/science.go`); `freeProduction / 5000` per tech level | no | no |
| Custom-science progress | weighted form of `ResearchTech` driven by `Race.Sciences[].(Drive\|Weapons\|Shields\|Cargo)` (`science.go`) | no | no |
-| Ship build progress | `PlanetProduceShipMass(L, Mat, Res) / ShipProductionCost(class.EmptyMass)` (combination of two existing exports) | partial | no |
+| Ship build progress / planet build rate (Phase 30)| `ProduceShipsInTurn(L, Mat, Res, mass)` (`pkg/calc/planet.go`, extracted from `controller.ProduceShip`); `ShipBuildCost` | yes | yes |
`partial` means the Go primitives exist in `pkg/calc/` but the
composition (and the conversion of TS-side `ReportPlanet`/
diff --git a/ui/docs/calculator-ux.md b/ui/docs/calculator-ux.md
new file mode 100644
index 0000000..81646c2
--- /dev/null
+++ b/ui/docs/calculator-ux.md
@@ -0,0 +1,135 @@
+# Ship Class Calculator — UX
+
+Phase 30 fuses the ship-class designer and a calculator into one sidebar
+tool (`lib/sidebar/calculator-tab.svelte`). It replaced the standalone
+designer view/route from Phases 17/18. All numeric math lives in
+`pkg/calc` and is reached through the `Core` WASM bridge; the calculator
+holds input state and orchestrates, it never computes.
+
+## Modes
+
+- **Calculator** (`ship`): the full tool — design area, derived results,
+ planet build, goal-seek.
+- **Modernization**: reuses the design area and shows per-block and
+ total `BlockUpgradeCost` from the current tech to an editable target
+ tech. The design-area component is extracted
+ (`lib/calculator/ship-design-area.svelte`) so the future ship-group
+ upgrade flow can reuse it.
+
+The `path` mode from the original plan was dropped (MVP path-finding is
+brute force); reach circles on the map replace it. `bombing` is folded
+in as a per-ship result rather than a separate mode.
+
+## Areas
+
+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.
+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.
+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 Phase 34.
+
+## 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):
+
+- **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.
+- **Goal-seek locks** on derived results. Pinning a result back-solves
+ the single input it claims, which then renders read-only (computed):
+
+ | result | claims |
+ | ------------- | ------------- |
+ | attack | weapons block |
+ | defence | shields block |
+ | empty speed | drive block |
+ | loaded speed | drive block |
+ | empty mass | cargo block |
+ | loaded mass | cargo load |
+
+ 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).
+
+## 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
+
+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.
+
+## Reach circles
+
+When an own planet is selected in calculator mode, the calculator
+publishes the planet origin and the design's loaded speed to a shared
+store (`lib/calculator/reach.svelte`). The map view
+(`lib/active-view/map.svelte`) reads it and draws 1–3 thin concentric
+reach circles (`map/reach-circles.ts`) for 1/2/3 turns. The ring count
+shrinks as speed grows: a ring is dropped once the previous one reaches
+the torus wrap-midpoint (half the shorter side) or the no-wrap map edge
+(farthest corner). The circles clear when the selection clears or the
+design is invalid.
+
+## State preservation and history
+
+Calculator inputs are component-local state. The sidebar keeps the tab
+mounted while the player navigates between active views, so inputs
+persist across view switches per the global state-preservation rule
+(`ui/docs/navigation.md`). Tech levels track the rendered report, so in
+history mode the calculator computes against the viewed snapshot's tech.
+
+The ship-classes table and the view/bottom menus open the calculator via
+a shared request store (`lib/calculator/load-request.svelte`): the
+in-game layout flips the sidebar to the calculator tab and the
+calculator loads the requested class (or starts a fresh design).
+
+## Layout and mobile
+
+Everything stacks vertically to fit the 18 rem sidebar; the design and
+result rows use compact two-column (ship/tech, empty/loaded) grids. On
+mobile the sidebar is the existing bottom-sheet/overlay; the calc bottom
+tab opens it.
+
+## Runtime note
+
+The new bridge functions are only present after `make wasm` rebuilds
+`ui/frontend/static/core.wasm` (needs TinyGo). Vitest injects a fake
+`Core` (`tests/fake-core.ts`) mirroring `pkg/calc`, so unit/component
+tests do not need the rebuild; the Playwright suite and the live app do.
diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md
index 75e4cf0..0ca04c8 100644
--- a/ui/docs/navigation.md
+++ b/ui/docs/navigation.md
@@ -27,16 +27,17 @@ separate dispatch component.
| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | Phase 23 |
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
-| `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) |
| `/games/:id/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` | Phase 21 |
`/games/:id` (no trailing view) redirects to `/games/:id/map`. The
-optional `:classId?` / `:scienceId?` segments on the designer
-routes match SvelteKit's `[[classId]]` syntax — `/designer/ship-class`
-opens the empty new-class form, `/designer/ship-class/{name}`
-opens the read-only view of the named class with the Delete
-affordance. Phase 17 lights up the ship-class CRUD path; Phase 18
-adds the live `pkg/calc/`-backed preview pane on top.
+optional `:scienceId?` segment on the science designer route matches
+SvelteKit's `[[scienceId]]` syntax — `/designer/science` opens the
+empty new-science form, `/designer/science/{name}` opens the named
+science. Phase 17/18 originally added a parallel ship-class designer
+route; Phase 30 removed it and folded ship-class design into the
+sidebar ship-class calculator (`lib/sidebar/calculator-tab.svelte`,
+see [calculator-ux.md](calculator-ux.md)), reached from the
+ship-classes table and the view/bottom menus.
The `entity` slug on the table route is kebab-case (`planets`,
`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`).
@@ -163,10 +164,11 @@ Tables, History, Settings, Logout) is the polish target for Phase 35
## Transient map overlays
Some views can push a transient overlay onto `/map` with a back
-affordance — for example, the ship-class designer pushes a
-range-preview overlay onto the map. The transient overlay clears
-when the user navigates to any other view via the header or the
-bottom-tabs.
+affordance. (Phase 30's calculator reach circles are a simpler,
+always-on map extra rather than a back-stacked overlay; the transient
+back-stack mechanism itself is still a Phase 34 concept.) A transient
+overlay clears when the user navigates to any other view via the header
+or the bottom-tabs.
Phase 10 documents this concept but does not implement the
back-stack mechanism. Phase 34 lands the back-stack alongside its
diff --git a/ui/frontend/src/lib/active-view/designer-ship-class.svelte b/ui/frontend/src/lib/active-view/designer-ship-class.svelte
deleted file mode 100644
index 5414bb8..0000000
--- a/ui/frontend/src/lib/active-view/designer-ship-class.svelte
+++ /dev/null
@@ -1,573 +0,0 @@
-
-
-
-
- {#if isViewMode}
- {#if viewing === null}
-