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>
14 KiB
Calc bridge
The Galaxy frontend renders predictive numbers (free production
potential, forecast output for a chosen production type, ship build
progress, tech progress) that depend on the same formulas the engine
uses at turn cutoff. To keep one source of truth, those formulas live
in Go under pkg/calc/ and are surfaced to the UI through a
Go → WASM → TypeScript bridge mounted under ui/core/calc/ and a
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. 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
The Go module galaxy/core/calc (ui/core/calc/ship.go) exposes
thin wrappers around pkg/calc/ship.go. Each is a one-line
passthrough — the bridge contains zero math. The same names appear
on the JS-side globalThis.galaxyCore (registered in
ui/wasm/main.go) and on the typed Core interface
(ui/frontend/src/platform/core/index.ts).
| Bridge function | pkg/calc/ source |
JS return shape | Used by |
|---|---|---|---|
driveEffective |
calc.DriveEffective(drive, driveTech) |
number |
designer preview (Speed input) |
emptyMass |
calc.EmptyMass(drive, weapons, armament, …) |
number|null |
designer preview (mass row) |
weaponsBlockMass |
calc.WeaponsBlockMass(weapons, armament) |
number|null |
designer preview, modernize cost preview |
fullMass |
calc.FullMass(emptyMass, carryingMass) |
number |
designer preview (full-load row) |
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 + 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) |
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
side and the other non-zero, so the bridge returns null instead of
silently zeroing. The ship-class form gates the preview behind its
own validateShipClass so a user-visible null is the safety net,
not the common path.
Composition (e.g., "full-load mass = fullMass(emptyMass, carryingMass(cargoCapacity, cargoTech))") happens in the TS preview
component, not in the bridge — the Go side stays purely a marshalling
adapter. Parity is exercised by ui/core/calc/ship_test.go, which
calls each wrapper alongside the direct pkg/calc/ function on the
same inputs and asserts byte-equal outputs.
Still-deferred slices
Phase 18's Go-side bridge is intentionally narrow: it covers ship
math and nothing else. Production forecasts, science, ship-build
progress, and reach (FligthDistance) still depend on either
inline TS arithmetic or the engine-shipped fields on GameReport.
See the table further down for what is missing and the per-feature
waivers below for the rationale on each deferral.
Current pkg/calc/ exports
| Function | Purpose |
|---|---|
ShipProductionCost(shipEmptyMass float64) float64 |
Production units required per unit of ship empty mass (×10). |
PlanetProduceShipMass(L, Mat, Res float64) float64 |
Ship mass produced per turn given free production L, material stockpile Mat, resources Res. |
DriveEffective, Speed, EmptyMass, FullMass, … |
Ship-level derivations (pkg/calc/ship.go). |
BlockUpgradeCost(blockMass, currentTech, target) |
Production cost of upgrading a single ship block (Phase 20 migrated this from controller). |
FligthDistance(driveTech), VisibilityDistance(...) |
Race-level reach formulas (pkg/calc/race.go). |
ValidateShipTypeValues, CheckShipTypeValueDWSC |
Ship-design validators (pkg/calc/validator.go). |
Nothing else lives in pkg/calc/ today. Production-side formulas
(industry / materials / per-tech research / production capacity) sit
in game/internal/model/game/planet.go and …/science.go and have
never been exported.
Required calc functions per UI feature
The table below tracks what UI features need from the bridge and whether the underlying Go function exists.
| UI feature | Go formula | In pkg/calc/? |
Surfaced to TS? |
|---|---|---|---|
| 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 / 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/
ShipClass to the formula inputs) is not implemented anywhere.
Phase 15 waiver
Phase 15 ships the inspector's planet production controls
(segmented control + sub-pickers + collapse-by-planetNumber
order command) but deliberately does not surface the per-type
forecast number. The planning gate explicitly raised the gap as
a blocker per the plan's audit clause ("if any are missing in
pkg/calc/, raise as blocker") and the project owner approved
deferring the forecast to a dedicated future bridge phase. The
inspector still renders the existing freeIndustry row (free
production potential) — that number is computed engine-side and
ships in the report payload, so no calc-bridge access is required
for it today.
Acceptance criterion 3 of Phase 15 ("forecast output number
reflects the chosen production type and matches pkg/calc/
outputs") is therefore intentionally not satisfied; the rewritten
Phase 15 stage text records this decision and points back at this
document.
Phase 16 waiver
Phase 16 introduces ship-reach filtering for the cargo-route destination picker. The engine formula is trivial:
flightDistance = driveTech * 40
The Go-side reference now lives in
pkg/calc/race.go as
FligthDistance(driveTech) float64 (alongside the matching
VisibilityDistance for in-space group reports — used in later
phases). The engine call sites
(game/internal/model/game/race.go.FlightDistance,
game/internal/controller/route.go.PlanetRouteSet) still wrap the
Go formula directly; promoting them to call pkg/calc/ is a
follow-up cleanup outside Phase 16's scope.
The original Phase 16 stage text described surfacing this through
pkg/calc/ and ui/core/calc/; with the calc-bridge phase still
deferred, implementing the WASM glue for one constant-time
multiplication would be premature scaffolding. The picker
therefore computes reach inline in TypeScript using
torusShortestDelta(planet.x, candidate.x, mapWidth) and
Math.hypot against 40 * report.localPlayerDrive, where
localPlayerDrive is decoded from the report's Player block by
matching Player.name to report.race
(api/game-state.ts.findLocalPlayerDrive).
When the calc-bridge phase ships, the inline formula is replaced
with a single call into the bridge — calc.FligthDistance(driveTech)
becomes the source of truth for both the picker and the
cargo-route auto-removal at turn cutoff. Until then, the UI
duplicates flightDistance knowingly — same precedent as the
production forecast deferral above.
Planned bridge growth (follow-up phases)
Phase 18 set up the canonical bridge layout (Go subpackage + WASM
registration + typed Core interface + parity tests). Future calc
work follows the same shape:
- Promote any still-engine-only formula from the table above into
pkg/calc/so the engine and the UI share one Go-side implementation. The engine continues to call them through itsgame/internal/...wrappers. - Add a thin one-line wrapper in
ui/core/calc/(new file per topic, e.g.ui/core/calc/planet.gofor production forecasts). No math in the bridge. - Register the function in
ui/wasm/main.gounderglobalThis.galaxyCore. - Extend the
Coreinterface inui/frontend/src/platform/core/index.tswith a typed signature and add the passthrough inwasm.ts.adaptBridge. - Add a parity test in
ui/core/calc/<topic>_test.goand a feature-level test underui/frontend/tests/. - Update this document — move the row from "missing" to the live surface table and link the test files.