9ae7b88b89
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>
302 lines
7.8 KiB
Go
302 lines
7.8 KiB
Go
package calc_test
|
|
|
|
import (
|
|
"testing"
|
|
|
|
source "galaxy/calc"
|
|
bridge "galaxy/core/calc"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// shipFixture is the input set passed to every parity check. Values
|
|
// are picked to exercise both the typical mid-tech ship and the
|
|
// invalid weapons/armament pairing path of EmptyMass /
|
|
// WeaponsBlockMass.
|
|
type shipFixture struct {
|
|
name string
|
|
drive float64
|
|
armament uint
|
|
weapons float64
|
|
shields float64
|
|
cargo float64
|
|
driveTech float64
|
|
cargoTech float64
|
|
}
|
|
|
|
func fixtures() []shipFixture {
|
|
return []shipFixture{
|
|
{
|
|
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,
|
|
driveTech: 1.5, cargoTech: 1.2,
|
|
},
|
|
{
|
|
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,
|
|
driveTech: 1, cargoTech: 1,
|
|
},
|
|
{
|
|
name: "invalid_armament_no_weapons",
|
|
drive: 5, armament: 3, weapons: 0, shields: 1, cargo: 2,
|
|
driveTech: 1, cargoTech: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestDriveEffectiveParity(t *testing.T) {
|
|
t.Parallel()
|
|
for _, f := range fixtures() {
|
|
t.Run(f.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
want := source.DriveEffective(f.drive, f.driveTech)
|
|
got := bridge.DriveEffective(f.drive, f.driveTech)
|
|
assert.Equal(t, want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmptyMassParity(t *testing.T) {
|
|
t.Parallel()
|
|
for _, f := range fixtures() {
|
|
t.Run(f.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
wantMass, wantOk := source.EmptyMass(f.drive, f.weapons, f.armament, f.shields, f.cargo)
|
|
gotMass, gotOk := bridge.EmptyMass(f.drive, f.weapons, f.armament, f.shields, f.cargo)
|
|
assert.Equal(t, wantOk, gotOk)
|
|
assert.Equal(t, wantMass, gotMass)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWeaponsBlockMassParity(t *testing.T) {
|
|
t.Parallel()
|
|
for _, f := range fixtures() {
|
|
t.Run(f.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
wantMass, wantOk := source.WeaponsBlockMass(f.weapons, f.armament)
|
|
gotMass, gotOk := bridge.WeaponsBlockMass(f.weapons, f.armament)
|
|
assert.Equal(t, wantOk, gotOk)
|
|
assert.Equal(t, wantMass, gotMass)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFullMassParity(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
emptyMass float64
|
|
carrying float64
|
|
}{
|
|
{"zero", 0, 0},
|
|
{"empty_only", 25, 0},
|
|
{"loaded", 25, 12.5},
|
|
{"negative_carrying_clamped_by_caller", 25, -3},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
want := source.FullMass(c.emptyMass, c.carrying)
|
|
got := bridge.FullMass(c.emptyMass, c.carrying)
|
|
assert.Equal(t, want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSpeedParity(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
driveEffective float64
|
|
fullMass float64
|
|
}{
|
|
{"zero_mass_returns_zero", 12, 0},
|
|
{"negative_mass_returns_zero", 12, -1},
|
|
{"typical", 12, 30},
|
|
{"fast_light_ship", 50, 5},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
want := source.Speed(c.driveEffective, c.fullMass)
|
|
got := bridge.Speed(c.driveEffective, c.fullMass)
|
|
assert.Equal(t, want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCargoCapacityParity(t *testing.T) {
|
|
t.Parallel()
|
|
for _, f := range fixtures() {
|
|
t.Run(f.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
want := source.CargoCapacity(f.cargo, f.cargoTech)
|
|
got := bridge.CargoCapacity(f.cargo, f.cargoTech)
|
|
assert.Equal(t, want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCarryingMassParity(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
load float64
|
|
cargoTech float64
|
|
}{
|
|
{"zero_load", 0, 1},
|
|
{"negative_load_returns_zero", -5, 1},
|
|
{"typical_high_tech", 24, 2},
|
|
{"low_tech_amplifies", 24, 0.5},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
want := source.CarryingMass(c.load, c.cargoTech)
|
|
got := bridge.CarryingMass(c.load, c.cargoTech)
|
|
assert.Equal(t, want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBlockUpgradeCostParity(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
blockMass float64
|
|
currentTech float64
|
|
targetTech float64
|
|
}{
|
|
{"zero_block_mass", 0, 1, 2},
|
|
{"target_equal_to_current", 5, 2, 2},
|
|
{"target_below_current", 5, 2, 1},
|
|
{"doubling_tech_on_mass_5", 5, 1, 2},
|
|
{"partial_step_2_to_2_5", 5, 2, 2.5},
|
|
{"high_tech_to_higher_tech", 12, 4, 6},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
want := source.BlockUpgradeCost(c.blockMass, c.currentTech, c.targetTech)
|
|
got := bridge.BlockUpgradeCost(c.blockMass, c.currentTech, c.targetTech)
|
|
assert.Equal(t, want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDesignerPreviewComposition exercises the exact composition the
|
|
// ship-class designer performs: empty mass, full-load mass via
|
|
// CarryingMass(CargoCapacity), max speed at empty, and range at full
|
|
// load. Catches regressions if a future bridge tweak silently changes
|
|
// the composition shape.
|
|
func TestDesignerPreviewComposition(t *testing.T) {
|
|
t.Parallel()
|
|
const (
|
|
drive = 8.0
|
|
armament = uint(2)
|
|
weapons = 5.0
|
|
shields = 3.0
|
|
cargo = 4.0
|
|
driveTech = 1.5
|
|
cargoTech = 1.2
|
|
)
|
|
emptyMass, ok := bridge.EmptyMass(drive, weapons, armament, shields, cargo)
|
|
require.True(t, ok)
|
|
cargoCap := bridge.CargoCapacity(cargo, cargoTech)
|
|
carryAtFull := bridge.CarryingMass(cargoCap, cargoTech)
|
|
fullLoadMass := bridge.FullMass(emptyMass, carryAtFull)
|
|
driveEff := bridge.DriveEffective(drive, driveTech)
|
|
maxSpeed := bridge.Speed(driveEff, emptyMass)
|
|
rangePerTurn := bridge.Speed(driveEff, fullLoadMass)
|
|
|
|
wantEmpty, _ := source.EmptyMass(drive, weapons, armament, shields, cargo)
|
|
wantCap := source.CargoCapacity(cargo, cargoTech)
|
|
wantCarry := source.CarryingMass(wantCap, cargoTech)
|
|
wantFull := source.FullMass(wantEmpty, wantCarry)
|
|
wantDE := source.DriveEffective(drive, driveTech)
|
|
wantMaxSpeed := source.Speed(wantDE, wantEmpty)
|
|
wantRange := source.Speed(wantDE, wantFull)
|
|
|
|
assert.Equal(t, wantEmpty, emptyMass)
|
|
assert.Equal(t, wantCap, cargoCap)
|
|
assert.Equal(t, wantCarry, carryAtFull)
|
|
assert.Equal(t, wantFull, fullLoadMass)
|
|
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)
|
|
})
|
|
}
|
|
}
|