feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user