feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s

Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 19:52:08 +02:00
parent 00159ddf7c
commit 9ae7b88b89
53 changed files with 3748 additions and 1298 deletions
+22
View File
@@ -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)
}
+57
View File
@@ -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)
})
}
}
+23
View File
@@ -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)
}
+73 -10
View File
@@ -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)
})
}
}
+42
View File
@@ -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)
}
+75
View File
@@ -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)
}
}