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:
+191
-12
@@ -27,6 +27,21 @@
|
||||
// - carryingMass(fields) -> number
|
||||
// - blockUpgradeCost(fields) -> number (Phase 20: modernize cost preview)
|
||||
//
|
||||
// Phase 30 adds the calculator bridge over the combat, planet-build, and
|
||||
// inverse goal-seek math in `pkg/calc` (combat / build helpers, plus the
|
||||
// single-target solvers):
|
||||
//
|
||||
// - effectiveAttack(fields) -> number
|
||||
// - effectiveDefence(fields) -> number
|
||||
// - bombingPower(fields) -> number
|
||||
// - shipBuildCost(fields) -> number
|
||||
// - produceShipsInTurn(fields) -> { ships, materialLeft, productionUsed, progress }
|
||||
// - weaponsForAttack(fields) -> number | null (null when infeasible)
|
||||
// - driveForSpeed(fields) -> number | null
|
||||
// - shieldsForDefence(fields) -> number | null
|
||||
// - cargoForEmptyMass(fields) -> number | null
|
||||
// - loadForFullMass(fields) -> number | null
|
||||
//
|
||||
// Field objects are plain JS objects with camelCase keys matching the
|
||||
// TypeScript `Core` interface, and bytes fields are Uint8Array.
|
||||
// Timestamps are JS Number (Unix milliseconds fit in 53 bits well past
|
||||
@@ -49,18 +64,28 @@ import (
|
||||
|
||||
func main() {
|
||||
js.Global().Set("galaxyCore", js.ValueOf(map[string]any{
|
||||
"signRequest": js.FuncOf(signRequest),
|
||||
"verifyResponse": js.FuncOf(verifyResponse),
|
||||
"verifyEvent": js.FuncOf(verifyEvent),
|
||||
"verifyPayloadHash": js.FuncOf(verifyPayloadHash),
|
||||
"driveEffective": js.FuncOf(driveEffective),
|
||||
"emptyMass": js.FuncOf(emptyMass),
|
||||
"weaponsBlockMass": js.FuncOf(weaponsBlockMass),
|
||||
"fullMass": js.FuncOf(fullMass),
|
||||
"speed": js.FuncOf(speed),
|
||||
"cargoCapacity": js.FuncOf(cargoCapacity),
|
||||
"carryingMass": js.FuncOf(carryingMass),
|
||||
"blockUpgradeCost": js.FuncOf(blockUpgradeCost),
|
||||
"signRequest": js.FuncOf(signRequest),
|
||||
"verifyResponse": js.FuncOf(verifyResponse),
|
||||
"verifyEvent": js.FuncOf(verifyEvent),
|
||||
"verifyPayloadHash": js.FuncOf(verifyPayloadHash),
|
||||
"driveEffective": js.FuncOf(driveEffective),
|
||||
"emptyMass": js.FuncOf(emptyMass),
|
||||
"weaponsBlockMass": js.FuncOf(weaponsBlockMass),
|
||||
"fullMass": js.FuncOf(fullMass),
|
||||
"speed": js.FuncOf(speed),
|
||||
"cargoCapacity": js.FuncOf(cargoCapacity),
|
||||
"carryingMass": js.FuncOf(carryingMass),
|
||||
"blockUpgradeCost": js.FuncOf(blockUpgradeCost),
|
||||
"effectiveAttack": js.FuncOf(effectiveAttack),
|
||||
"effectiveDefence": js.FuncOf(effectiveDefence),
|
||||
"bombingPower": js.FuncOf(bombingPower),
|
||||
"shipBuildCost": js.FuncOf(shipBuildCost),
|
||||
"produceShipsInTurn": js.FuncOf(produceShipsInTurn),
|
||||
"weaponsForAttack": js.FuncOf(weaponsForAttack),
|
||||
"driveForSpeed": js.FuncOf(driveForSpeed),
|
||||
"shieldsForDefence": js.FuncOf(shieldsForDefence),
|
||||
"cargoForEmptyMass": js.FuncOf(cargoForEmptyMass),
|
||||
"loadForFullMass": js.FuncOf(loadForFullMass),
|
||||
}))
|
||||
|
||||
// Block forever so the Go runtime stays alive while JS keeps calling
|
||||
@@ -241,6 +266,160 @@ func blockUpgradeCost(_ js.Value, args []js.Value) any {
|
||||
return js.ValueOf(calc.BlockUpgradeCost(blockMass, currentTech, targetTech))
|
||||
}
|
||||
|
||||
// effectiveAttack bridges `calc.EffectiveAttack`. Input
|
||||
// `{ weapons, weaponsTech }`, output a JS number.
|
||||
func effectiveAttack(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
weapons := args[0].Get("weapons").Float()
|
||||
weaponsTech := args[0].Get("weaponsTech").Float()
|
||||
return js.ValueOf(calc.EffectiveAttack(weapons, weaponsTech))
|
||||
}
|
||||
|
||||
// effectiveDefence bridges `calc.EffectiveDefence`. Input
|
||||
// `{ shields, shieldsTech, fullMass }`, output a JS number (zero when
|
||||
// fullMass is non-positive).
|
||||
func effectiveDefence(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
shields := args[0].Get("shields").Float()
|
||||
shieldsTech := args[0].Get("shieldsTech").Float()
|
||||
fullMass := args[0].Get("fullMass").Float()
|
||||
return js.ValueOf(calc.EffectiveDefence(shields, shieldsTech, fullMass))
|
||||
}
|
||||
|
||||
// bombingPower bridges `calc.BombingPower`. Input
|
||||
// `{ weapons, weaponsTech, armament, number }`, output a JS number.
|
||||
func bombingPower(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
weapons := args[0].Get("weapons").Float()
|
||||
weaponsTech := args[0].Get("weaponsTech").Float()
|
||||
armament := args[0].Get("armament").Float()
|
||||
number := args[0].Get("number").Float()
|
||||
return js.ValueOf(calc.BombingPower(weapons, weaponsTech, armament, number))
|
||||
}
|
||||
|
||||
// shipBuildCost bridges `calc.ShipBuildCost`. Input
|
||||
// `{ shipMass, material, resources }`, output a JS number.
|
||||
func shipBuildCost(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
shipMass := args[0].Get("shipMass").Float()
|
||||
material := args[0].Get("material").Float()
|
||||
resources := args[0].Get("resources").Float()
|
||||
return js.ValueOf(calc.ShipBuildCost(shipMass, material, resources))
|
||||
}
|
||||
|
||||
// produceShipsInTurn bridges `calc.ProduceShipsInTurn`. Input
|
||||
// `{ productionAvailable, material, resources, shipMass }`, output a JS
|
||||
// object `{ ships, materialLeft, productionUsed, progress }`.
|
||||
func produceShipsInTurn(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
productionAvailable := args[0].Get("productionAvailable").Float()
|
||||
material := args[0].Get("material").Float()
|
||||
resources := args[0].Get("resources").Float()
|
||||
shipMass := args[0].Get("shipMass").Float()
|
||||
ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn(
|
||||
productionAvailable, material, resources, shipMass,
|
||||
)
|
||||
return js.ValueOf(map[string]any{
|
||||
"ships": float64(ships),
|
||||
"materialLeft": materialLeft,
|
||||
"productionUsed": productionUsed,
|
||||
"progress": progress,
|
||||
})
|
||||
}
|
||||
|
||||
// weaponsForAttack bridges `calc.WeaponsForAttack`. Input
|
||||
// `{ targetAttack, weaponsTech }`, output a JS number or null when the
|
||||
// request is infeasible.
|
||||
func weaponsForAttack(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
targetAttack := args[0].Get("targetAttack").Float()
|
||||
weaponsTech := args[0].Get("weaponsTech").Float()
|
||||
v, ok := calc.WeaponsForAttack(targetAttack, weaponsTech)
|
||||
if !ok {
|
||||
return js.Null()
|
||||
}
|
||||
return js.ValueOf(v)
|
||||
}
|
||||
|
||||
// driveForSpeed bridges `calc.DriveForSpeed`. Input
|
||||
// `{ targetSpeed, driveTech, restMass }`, output a JS number or null when
|
||||
// the target is unreachable.
|
||||
func driveForSpeed(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
targetSpeed := args[0].Get("targetSpeed").Float()
|
||||
driveTech := args[0].Get("driveTech").Float()
|
||||
restMass := args[0].Get("restMass").Float()
|
||||
v, ok := calc.DriveForSpeed(targetSpeed, driveTech, restMass)
|
||||
if !ok {
|
||||
return js.Null()
|
||||
}
|
||||
return js.ValueOf(v)
|
||||
}
|
||||
|
||||
// shieldsForDefence bridges `calc.ShieldsForDefence`. Input
|
||||
// `{ targetDefence, shieldsTech, restMass }`, output a JS number or null
|
||||
// when the request is infeasible.
|
||||
func shieldsForDefence(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
targetDefence := args[0].Get("targetDefence").Float()
|
||||
shieldsTech := args[0].Get("shieldsTech").Float()
|
||||
restMass := args[0].Get("restMass").Float()
|
||||
v, ok := calc.ShieldsForDefence(targetDefence, shieldsTech, restMass)
|
||||
if !ok {
|
||||
return js.Null()
|
||||
}
|
||||
return js.ValueOf(v)
|
||||
}
|
||||
|
||||
// cargoForEmptyMass bridges `calc.CargoForEmptyMass`. Input
|
||||
// `{ targetEmptyMass, restMass }`, output a JS number or null when the
|
||||
// target is below the fixed block mass.
|
||||
func cargoForEmptyMass(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
targetEmptyMass := args[0].Get("targetEmptyMass").Float()
|
||||
restMass := args[0].Get("restMass").Float()
|
||||
v, ok := calc.CargoForEmptyMass(targetEmptyMass, restMass)
|
||||
if !ok {
|
||||
return js.Null()
|
||||
}
|
||||
return js.ValueOf(v)
|
||||
}
|
||||
|
||||
// loadForFullMass bridges `calc.LoadForFullMass`. Input
|
||||
// `{ targetFullMass, emptyMass, cargoTech }`, output a JS number or null
|
||||
// when the target is below the empty mass.
|
||||
func loadForFullMass(_ js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
return js.Null()
|
||||
}
|
||||
targetFullMass := args[0].Get("targetFullMass").Float()
|
||||
emptyMass := args[0].Get("emptyMass").Float()
|
||||
cargoTech := args[0].Get("cargoTech").Float()
|
||||
v, ok := calc.LoadForFullMass(targetFullMass, emptyMass, cargoTech)
|
||||
if !ok {
|
||||
return js.Null()
|
||||
}
|
||||
return js.ValueOf(v)
|
||||
}
|
||||
|
||||
// copyBytesFromJS materialises a JS Uint8Array (or any indexable
|
||||
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
|
||||
// because TinyGo's implementation panics on values it does not
|
||||
|
||||
Reference in New Issue
Block a user