Files
Ilia Denisov 3626998a33 ui/phase-20: ship-group inspector actions
Eight ship-group operations land on the inspector behind a single
inline-form panel: split, send, load, unload, modernize, dismantle,
transfer, join fleet. Each action either appends a typed command to
the local order draft or surfaces a tooltip explaining the
disabled state. Partial-ship operations emit an implicit
breakShipGroup command before the targeted action so the engine
sees a clean (Break, Action) pair on the wire.

`pkg/calc.BlockUpgradeCost` migrates from
`game/internal/controller/ship_group_upgrade.go` so the calc
bridge can wrap a pure pkg/calc formula; the controller now
imports it. The bridge surfaces the function as
`core.blockUpgradeCost`, which the inspector calls once per ship
block to render the modernize cost preview.

`GameReport.otherRaces` is decoded from the report's player block
(non-extinct, ≠ self) and feeds the transfer-to-race picker. The
planet inspector's stationed-ship rows become clickable for own
groups so the actions panel is reachable from the standard click
flow (the renderer continues to hide on-planet groups).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 16:27:55 +02:00

275 lines
9.4 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)
//
// 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),
}))
// 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))
}
// 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
}