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:
+41
-4
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user