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>
454 lines
16 KiB
Go
454 lines
16 KiB
Go
// Command wasm is the TinyGo WebAssembly entry point for the Galaxy UI
|
|
// client. It exposes a small "compute boundary" API on
|
|
// `globalThis.galaxyCore` so the TypeScript-side `WasmCore` adapter can
|
|
// call into the Go canonical-bytes serializer and signature verifier
|
|
// without duplicating the contract in JavaScript.
|
|
//
|
|
// Public surface (all functions live under `globalThis.galaxyCore`):
|
|
//
|
|
// - signRequest(fields) -> Uint8Array
|
|
// Returns the canonical bytes for a v1 request envelope. The actual
|
|
// Ed25519 signing happens outside WASM (Phase 6 introduces WebCrypto
|
|
// with non-exportable keys).
|
|
// - verifyResponse(publicKey, signature, fields) -> boolean
|
|
// - verifyEvent(publicKey, signature, fields) -> boolean
|
|
// - verifyPayloadHash(payloadBytes, payloadHash) -> boolean
|
|
//
|
|
// Phase 18 adds the ship-math bridge over `pkg/calc/ship.go`. Each
|
|
// function is a thin wrapper around the same-named upstream calc
|
|
// function (zero math here, the bridge only marshals JS objects):
|
|
//
|
|
// - driveEffective(fields) -> number
|
|
// - emptyMass(fields) -> number | null (null when invalid)
|
|
// - weaponsBlockMass(fields) -> number | null (null when invalid)
|
|
// - fullMass(fields) -> number
|
|
// - speed(fields) -> number
|
|
// - cargoCapacity(fields) -> number
|
|
// - 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
|
|
// year 2200).
|
|
//
|
|
// All functions return either a Uint8Array, a number, a boolean, null,
|
|
// or fail closed. They never throw — callers may inspect the result
|
|
// or rely on the canon-byte length to detect malformed input.
|
|
|
|
//go:build js && wasm
|
|
|
|
package main
|
|
|
|
import (
|
|
"syscall/js"
|
|
|
|
"galaxy/core/calc"
|
|
"galaxy/core/canon"
|
|
)
|
|
|
|
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),
|
|
"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
|
|
// the registered functions.
|
|
select {}
|
|
}
|
|
|
|
func signRequest(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
fields := canon.RequestSigningFields{
|
|
ProtocolVersion: args[0].Get("protocolVersion").String(),
|
|
DeviceSessionID: args[0].Get("deviceSessionId").String(),
|
|
MessageType: args[0].Get("messageType").String(),
|
|
TimestampMS: int64(args[0].Get("timestampMs").Float()),
|
|
RequestID: args[0].Get("requestId").String(),
|
|
PayloadHash: copyBytesFromJS(args[0].Get("payloadHash")),
|
|
}
|
|
return copyBytesToJS(canon.BuildRequestSigningInput(fields))
|
|
}
|
|
|
|
func verifyResponse(_ js.Value, args []js.Value) any {
|
|
if len(args) != 3 {
|
|
return js.ValueOf(false)
|
|
}
|
|
fields := canon.ResponseSigningFields{
|
|
ProtocolVersion: args[2].Get("protocolVersion").String(),
|
|
RequestID: args[2].Get("requestId").String(),
|
|
TimestampMS: int64(args[2].Get("timestampMs").Float()),
|
|
ResultCode: args[2].Get("resultCode").String(),
|
|
PayloadHash: copyBytesFromJS(args[2].Get("payloadHash")),
|
|
}
|
|
publicKey := copyBytesFromJS(args[0])
|
|
signature := copyBytesFromJS(args[1])
|
|
if err := canon.VerifyResponseSignature(publicKey, signature, fields); err != nil {
|
|
return js.ValueOf(false)
|
|
}
|
|
return js.ValueOf(true)
|
|
}
|
|
|
|
func verifyEvent(_ js.Value, args []js.Value) any {
|
|
if len(args) != 3 {
|
|
return js.ValueOf(false)
|
|
}
|
|
fields := canon.EventSigningFields{
|
|
EventType: args[2].Get("eventType").String(),
|
|
EventID: args[2].Get("eventId").String(),
|
|
TimestampMS: int64(args[2].Get("timestampMs").Float()),
|
|
RequestID: args[2].Get("requestId").String(),
|
|
TraceID: args[2].Get("traceId").String(),
|
|
PayloadHash: copyBytesFromJS(args[2].Get("payloadHash")),
|
|
}
|
|
publicKey := copyBytesFromJS(args[0])
|
|
signature := copyBytesFromJS(args[1])
|
|
if err := canon.VerifyEventSignature(publicKey, signature, fields); err != nil {
|
|
return js.ValueOf(false)
|
|
}
|
|
return js.ValueOf(true)
|
|
}
|
|
|
|
func verifyPayloadHash(_ js.Value, args []js.Value) any {
|
|
if len(args) != 2 {
|
|
return js.ValueOf(false)
|
|
}
|
|
payloadBytes := copyBytesFromJS(args[0])
|
|
payloadHash := copyBytesFromJS(args[1])
|
|
if err := canon.VerifyPayloadHash(payloadBytes, payloadHash); err != nil {
|
|
return js.ValueOf(false)
|
|
}
|
|
return js.ValueOf(true)
|
|
}
|
|
|
|
// driveEffective bridges `calc.DriveEffective`. Input
|
|
// `{ drive, driveTech }`, output a JS number.
|
|
func driveEffective(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
drive := args[0].Get("drive").Float()
|
|
driveTech := args[0].Get("driveTech").Float()
|
|
return js.ValueOf(calc.DriveEffective(drive, driveTech))
|
|
}
|
|
|
|
// emptyMass bridges `calc.EmptyMass`. Input
|
|
// `{ drive, weapons, armament, shields, cargo }`, output a JS number
|
|
// or null when the upstream validator rejects the weapons/armament
|
|
// pairing.
|
|
func emptyMass(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
drive := args[0].Get("drive").Float()
|
|
weapons := args[0].Get("weapons").Float()
|
|
armament := uint(args[0].Get("armament").Int())
|
|
shields := args[0].Get("shields").Float()
|
|
cargo := args[0].Get("cargo").Float()
|
|
mass, ok := calc.EmptyMass(drive, weapons, armament, shields, cargo)
|
|
if !ok {
|
|
return js.Null()
|
|
}
|
|
return js.ValueOf(mass)
|
|
}
|
|
|
|
// weaponsBlockMass bridges `calc.WeaponsBlockMass`. Input
|
|
// `{ weapons, armament }`, output a JS number or null on the same
|
|
// invalid pairing as emptyMass.
|
|
func weaponsBlockMass(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
weapons := args[0].Get("weapons").Float()
|
|
armament := uint(args[0].Get("armament").Int())
|
|
mass, ok := calc.WeaponsBlockMass(weapons, armament)
|
|
if !ok {
|
|
return js.Null()
|
|
}
|
|
return js.ValueOf(mass)
|
|
}
|
|
|
|
// fullMass bridges `calc.FullMass`. Input
|
|
// `{ emptyMass, carryingMass }`, output a JS number.
|
|
func fullMass(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
em := args[0].Get("emptyMass").Float()
|
|
cm := args[0].Get("carryingMass").Float()
|
|
return js.ValueOf(calc.FullMass(em, cm))
|
|
}
|
|
|
|
// speed bridges `calc.Speed`. Input `{ driveEffective, fullMass }`,
|
|
// output a JS number (zero when fullMass is non-positive).
|
|
func speed(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
de := args[0].Get("driveEffective").Float()
|
|
fm := args[0].Get("fullMass").Float()
|
|
return js.ValueOf(calc.Speed(de, fm))
|
|
}
|
|
|
|
// cargoCapacity bridges `calc.CargoCapacity`. Input
|
|
// `{ cargo, cargoTech }`, output a JS number (cargo units of hold).
|
|
func cargoCapacity(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
cargo := args[0].Get("cargo").Float()
|
|
cargoTech := args[0].Get("cargoTech").Float()
|
|
return js.ValueOf(calc.CargoCapacity(cargo, cargoTech))
|
|
}
|
|
|
|
// carryingMass bridges `calc.CarryingMass`. Input
|
|
// `{ load, cargoTech }`, output a JS number (mass of `load` cargo
|
|
// units at the player's cargo tech).
|
|
func carryingMass(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
load := args[0].Get("load").Float()
|
|
cargoTech := args[0].Get("cargoTech").Float()
|
|
return js.ValueOf(calc.CarryingMass(load, cargoTech))
|
|
}
|
|
|
|
// blockUpgradeCost bridges `calc.BlockUpgradeCost`. Input
|
|
// `{ blockMass, currentTech, targetTech }`, output a JS number
|
|
// (production cost of moving one block from currentTech to
|
|
// targetTech; zero when blockMass is zero or targetTech is not
|
|
// above currentTech).
|
|
func blockUpgradeCost(_ js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
return js.Null()
|
|
}
|
|
blockMass := args[0].Get("blockMass").Float()
|
|
currentTech := args[0].Get("currentTech").Float()
|
|
targetTech := args[0].Get("targetTech").Float()
|
|
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
|
|
// recognise as Uint8Array — a check that misfires for Uint8Arrays
|
|
// constructed from Node's Buffer in Vitest's jsdom environment. The
|
|
// per-element copy is slower but always correct, and the canonical
|
|
// envelope payloads are small enough (≤ a few hundred bytes) that the
|
|
// difference is negligible.
|
|
func copyBytesFromJS(value js.Value) []byte {
|
|
if value.IsUndefined() || value.IsNull() {
|
|
return nil
|
|
}
|
|
length := value.Length()
|
|
if length == 0 {
|
|
return nil
|
|
}
|
|
dst := make([]byte, length)
|
|
for i := 0; i < length; i++ {
|
|
dst[i] = byte(value.Index(i).Int())
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// copyBytesToJS allocates a JS Uint8Array of the same length as src and
|
|
// copies src into it. The result is safe to hand back across the
|
|
// JS/Go boundary as the canonical-bytes return value.
|
|
func copyBytesToJS(src []byte) js.Value {
|
|
dst := js.Global().Get("Uint8Array").New(len(src))
|
|
js.CopyBytesToJS(dst, src)
|
|
return dst
|
|
}
|