ui/phase-18: ship-class calc bridge with live designer preview

Wires pkg/calc/ship.go into the WASM Core boundary as seven thin
wrappers (DriveEffective, EmptyMass, WeaponsBlockMass, FullMass,
Speed, CargoCapacity, CarryingMass). The ship-class designer reads
Core through a new CORE_CONTEXT_KEY populated by the in-game layout
and renders a five-row preview pane (mass, full-load mass, max
speed, range at full load, cargo capacity) that updates reactively
on every form edit and on the player's localPlayer{Drive,Weapons,
Shields,Cargo} tech levels — three of which are now decoded from
the report's Player block alongside the existing localPlayerDrive.

CarryingMass is the seventh wrapper added to the original six-function
list so that "full-load mass" composes through pkg/calc/ functions
without putting math in TypeScript.
This commit is contained in:
Ilia Denisov
2026-05-09 23:14:40 +02:00
parent 721fa2172d
commit e4dc0ce029
25 changed files with 1056 additions and 64 deletions
+42 -21
View File
@@ -2012,28 +2012,44 @@ Targeted tests:
class, list it, delete it; rejected-submit kept; field-validation
kept (Save disabled with localised tooltip).
## Phase 18. Ship Classes — Calc Bridge
## ~~Phase 18. Ship Classes — Calc Bridge~~
Status: pending.
Status: done.
Goal: wire `pkg/calc/` ship math into the designer for live mass,
speed, range, and cargo capacity previews.
Artifacts:
- `ui/core/calc/ship.go` thin Go bridge wrapping `pkg/calc/.FullMass`,
`EmptyMass`, `Speed`, `CargoCapacity`, `WeaponsBlockMass`,
`DriveEffective` in JSON-marshallable signatures, exported through
the `Core` API
- `ui/frontend/src/platform/core/index.ts` extends `Core` interface
with the new calc methods
- live-updating preview pane in the ship-class designer showing mass,
full-load mass, max speed, range, and cargo capacity at the player's
current tech levels
- audit step recorded in `ui/docs/calc-bridge.md`: every wired
function listed against its `pkg/calc/` source
- if any required `pkg/calc/` function is missing, this phase raises a
blocker and the function is added to `pkg/calc/` first (owner-led)
- `ui/core/calc/ship.go` thin Go bridge wrapping seven functions
from `pkg/calc/ship.go` — `DriveEffective`, `EmptyMass`,
`WeaponsBlockMass`, `FullMass`, `Speed`, `CargoCapacity`,
`CarryingMass` — each as a one-line passthrough; the seventh
function (`CarryingMass`) was added during stage implementation
to let the preview compose `full-load mass` from `CargoCapacity`
without injecting math into TS;
- `ui/wasm/main.go` registers the seven wrappers under
`globalThis.galaxyCore`; `ui/frontend/src/platform/core/index.ts`
extends `Core` with the matching typed methods (`emptyMass` and
`weaponsBlockMass` return `number | null`, mirroring the Go
`(_, false)` validator path);
- `ui/frontend/src/api/game-state.ts` extends `GameReport` with
`localPlayerWeapons`, `localPlayerShields`, `localPlayerCargo`
alongside the existing `localPlayerDrive`. The decoder reads
all four from the `Player` row in the report's player block.
Phases 19-21 reuse these fields without re-extending the report;
- `ui/frontend/src/lib/core-context.svelte.ts` exposes a
`CoreHolder` through `CORE_CONTEXT_KEY`. The in-game layout
(`routes/games/[id]/+layout.svelte`) populates the holder after
`loadCore()` resolves, so the designer reads `Core` from context
without re-booting WASM;
- live-updating preview pane in the ship-class designer showing
empty mass, full-load mass, max speed (at empty), range at full
load, and cargo capacity per ship at the player's current tech
levels; the pane only renders when the form passes validation
*and* `Core` is ready;
- audit step recorded in `ui/docs/calc-bridge.md`: live surface
table lists every wired function against its `pkg/calc/` source.
Dependencies: Phases 5 (Core skeleton), 17.
@@ -2047,11 +2063,14 @@ Acceptance criteria:
Targeted tests:
- Go parity tests in `ui/core/calc/` against `pkg/calc/` outputs on
shared fixtures;
- Vitest snapshot tests for the preview pane on canonical inputs;
- Playwright e2e: edit a ship class, observe preview updates and
submit, confirm server-side mass matches.
- Go parity tests in `ui/core/calc/ship_test.go` against `pkg/calc/`
outputs on shared fixtures, plus a composition test that exercises
the exact preview pipeline (empty → cargo capacity → carrying mass
→ full-load mass → speed at empty + at full);
- Vitest coverage in `ui/frontend/tests/designer-ship-class.test.ts`
asserts preview hidden until validation passes, hidden when no
`Core` is available, renders five rows with computed values once
the form is valid, and reactively refreshes on subsequent edits.
## Phase 19. Inspector — Ship Group (Read-Only)
@@ -2112,7 +2131,9 @@ Artifacts:
`SendGroup`, `LoadCargo`, `UnloadCargo`, `Modernize`, `Dismantle`,
`TransferToRace`, `AssignToFleet` command variants
- `Send` action picks destination through a planet picker filtered by
the group's reach (uses `pkg/calc/` reach function via Core)
the group's reach (uses `pkg/calc/` reach function via Core; the
player's tech levels are already on `GameReport.localPlayer*` from
Phase 18, no extra plumbing needed)
- `Modernize` cost preview using `pkg/calc/` formula via Core
- confirmation dialog for `Dismantle` over a foreign planet with
colonists onboard (special-case from [`rules.txt`](../game/rules.txt): colonists die)
+19
View File
@@ -39,6 +39,8 @@ parity and round-trip sign/verify are exercised by
```text
ui/core/
├── go.mod module galaxy/core (Go 1.26.0)
├── calc/ ship-math wrappers over `pkg/calc/ship.go`
│ └── ship.go Phase 18 designer preview bridge
├── canon/ canonical-bytes builders and verifiers
│ ├── canon.go length-prefix helpers
│ ├── request.go galaxy-request-v1 fields and signing input
@@ -88,6 +90,23 @@ ui/core/
- Sentinel errors: `ErrInvalidPrivateKey`, `ErrInvalidPublicKey`,
`ErrInvalidPublicKeyEncoding`.
### `galaxy/core/calc`
Thin Go bridge over `pkg/calc/ship.go`, surfaced via WASM to the
Phase 18 ship-class designer preview. Each function is a one-line
passthrough — no math lives here.
- `DriveEffective(drive, driveTech float64) float64`
- `EmptyMass(drive, weapons float64, armament uint, shields, cargo float64) (float64, bool)`
- `WeaponsBlockMass(weapons float64, armament uint) (float64, bool)`
- `FullMass(emptyMass, carryingMass float64) float64`
- `Speed(driveEffective, fullMass float64) float64`
- `CargoCapacity(cargo, cargoTech float64) float64`
- `CarryingMass(load, cargoTech float64) float64`
The full audit trail (which UI feature uses what, what is still
deferred) lives in [`ui/docs/calc-bridge.md`](../docs/calc-bridge.md).
### `galaxy/core/types`
- `RequestEnvelope`, `ResponseEnvelope`, `EventEnvelope` — full Go
+57
View File
@@ -0,0 +1,57 @@
// Package calc is the WASM-side bridge over `galaxy/calc`'s ship math.
// Each function is a one-line passthrough: signatures match the
// underlying `pkg/calc/ship.go` exactly so the bridge contains zero
// math beyond the call. Wrapping `pkg/calc` here keeps the JS/Go
// surface in one file and lets the canonical math live in a single
// upstream package shared with the engine.
package calc
import "galaxy/calc"
// DriveEffective wraps `calc.DriveEffective` (`pkg/calc/ship.go`):
// effective drive power equals the ship's drive block multiplied by
// the player's drive tech level.
func DriveEffective(drive, driveTech float64) float64 {
return calc.DriveEffective(drive, driveTech)
}
// EmptyMass wraps `calc.EmptyMass` (`pkg/calc/ship.go`): mass of the
// ship without cargo. Returns ok == false when the weapons/armament
// pair is invalid (one zero, the other non-zero).
func EmptyMass(drive, weapons float64, armament uint, shields, cargo float64) (float64, bool) {
return calc.EmptyMass(drive, weapons, armament, shields, cargo)
}
// WeaponsBlockMass wraps `calc.WeaponsBlockMass` (`pkg/calc/ship.go`):
// mass of the weapons sub-block. Returns ok == false on the same
// invalid pairing as EmptyMass.
func WeaponsBlockMass(weapons float64, armament uint) (float64, bool) {
return calc.WeaponsBlockMass(weapons, armament)
}
// FullMass wraps `calc.FullMass` (`pkg/calc/ship.go`): empty mass plus
// the mass of the carried cargo.
func FullMass(emptyMass, carryingMass float64) float64 {
return calc.FullMass(emptyMass, carryingMass)
}
// Speed wraps `calc.Speed` (`pkg/calc/ship.go`): light-years per turn,
// equal to effective drive times 20 divided by the ship's full mass.
// Zero when fullMass is non-positive.
func Speed(driveEffective, fullMass float64) float64 {
return calc.Speed(driveEffective, fullMass)
}
// CargoCapacity wraps `calc.CargoCapacity` (`pkg/calc/ship.go`):
// hold capacity of one ship in cargo units, scaled by the player's
// cargo tech.
func CargoCapacity(cargo, cargoTech float64) float64 {
return calc.CargoCapacity(cargo, cargoTech)
}
// CarryingMass wraps `calc.CarryingMass` (`pkg/calc/ship.go`): mass of
// a payload of `load` cargo units at the player's cargo tech. Used by
// the designer preview to derive full-load mass from CargoCapacity.
func CarryingMass(load, cargoTech float64) float64 {
return calc.CarryingMass(load, cargoTech)
}
+213
View File
@@ -0,0 +1,213 @@
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)
})
}
}
// 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)
}
+4 -1
View File
@@ -2,7 +2,10 @@ module galaxy/core
go 1.26.0
require github.com/stretchr/testify v1.11.1
require (
galaxy/calc v0.0.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
+71 -19
View File
@@ -4,12 +4,58 @@ 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 planned
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/`.
matching TS adapter in `ui/frontend/src/platform/core/`.
The bridge does not exist yet. This document is the audit trail for
what it must expose, what is already in place, and what is missing.
Phase 18 lands the **ship-math slice** of the bridge — everything
the ship-class designer needs to render its preview pane. Other
slices (production forecast, science research, ship build progress)
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 (Phase 18)
The Go module `galaxy/core/calc` (`ui/core/calc/ship.go`) exposes
seven thin wrappers around `pkg/calc/ship.go`. Each is a one-line
passthrough — the bridge contains zero math. The same seven 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` | reserved for future stages |
| `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)|
`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
@@ -32,6 +78,7 @@ 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 |
| 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 |
@@ -100,20 +147,25 @@ cargo-route auto-removal at turn cutoff. Until then, the UI
duplicates `flightDistance` knowingly — same precedent as the
production forecast deferral above.
## Planned bridge shape (follow-up phase)
## Planned bridge growth (follow-up phases)
When the bridge phase lands, the contract should be:
Phase 18 set up the canonical bridge layout (Go subpackage + WASM
registration + typed `Core` interface + parity tests). Future calc
work follows the same shape:
1. Promote every formula in the table above into `pkg/calc/` so the
engine and the UI share one Go-side implementation. The engine
continues to call them through `game/internal/...` wrappers.
2. Mount a `ui/core/calc/` Go module that re-exports the subset the
UI needs. Keep it WASM-friendly (no `unsafe`, no goroutines,
simple in/out values).
3. Wire the WASM glue in `ui/wasm/main.go` so each calc function is
reachable from `globalThis.galaxyCore`.
4. Add a TypeScript adapter under `ui/frontend/src/platform/core/`
that wraps the WASM calls in typed helpers
(`forecastIndustry(freeProduction, …)` etc.).
5. Update this document with the live function inventory and
delete the "missing" rows above.
1. 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 its
`game/internal/...` wrappers.
2. Add a thin one-line wrapper in `ui/core/calc/` (new file per
topic, e.g. `ui/core/calc/planet.go` for production forecasts).
No math in the bridge.
3. Register the function in `ui/wasm/main.go` under
`globalThis.galaxyCore`.
4. Extend the `Core` interface in
`ui/frontend/src/platform/core/index.ts` with a typed signature
and add the passthrough in `wasm.ts.adaptBridge`.
5. Add a parity test in `ui/core/calc/<topic>_test.go` and a
feature-level test under `ui/frontend/tests/`.
6. Update this document — move the row from "missing" to the live
surface table and link the test files.
+43 -17
View File
@@ -149,15 +149,23 @@ export interface GameReport {
*/
routes: ReportRoute[];
/**
* localPlayerDrive is the local player's drive tech level. The
* engine's reach formula is `40 * driveTech`
* (`game/internal/model/game/race.go.FlightDistance`); the
* cargo-route picker filters destinations through it, so the
* value is propagated all the way through `applyOrderOverlay`
* to the inspector subsection. Zero on boot or when the
* report's player block is missing the local entry.
* localPlayerDrive, localPlayerWeapons, localPlayerShields,
* localPlayerCargo carry the local player's four tech levels,
* read from the matching `Player` row in the report. Drive
* powers reach (`40 * driveTech`,
* `game/internal/model/game/race.go.FlightDistance`) and the
* cargo-route picker; cargo feeds the ship-class designer's
* cargo-capacity preview (`pkg/calc/ship.go.CargoCapacity` and
* `CarryingMass`); weapons and shields are surfaced ahead of
* Phases 19-21 (ship-group inspector, science designer) so
* future patches do not need to re-extend the report decoder.
* All four are zero on boot or when the report's player block
* is missing the local entry.
*/
localPlayerDrive: number;
localPlayerWeapons: number;
localPlayerShields: number;
localPlayerCargo: number;
}
export async function fetchGameReport(
@@ -290,7 +298,7 @@ function decodeReport(report: Report): GameReport {
const raceName = report.race() ?? "";
const routes = decodeReportRoutes(report);
const localPlayerDrive = findLocalPlayerDrive(report, raceName);
const localTech = findLocalPlayerTech(report, raceName);
return {
turn: Number(report.turn()),
@@ -301,7 +309,10 @@ function decodeReport(report: Report): GameReport {
race: raceName,
localShipClass,
routes,
localPlayerDrive,
localPlayerDrive: localTech.drive,
localPlayerWeapons: localTech.weapons,
localPlayerShields: localTech.shields,
localPlayerCargo: localTech.cargo,
};
}
@@ -356,24 +367,39 @@ function compareRouteEntriesByLoadType(
return LOAD_TYPE_ORDER[a.loadType] - LOAD_TYPE_ORDER[b.loadType];
}
interface LocalPlayerTech {
drive: number;
weapons: number;
shields: number;
cargo: number;
}
/**
* findLocalPlayerDrive locates the local player's drive tech
* level by matching `Player.name` against the report's `race`
* field (the engine uses race name as the runtime player
* identifier). Returns 0 when the lookup fails — boot state, an
* findLocalPlayerTech locates the local player's four tech levels
* by matching `Player.name` against the report's `race` field (the
* engine uses race name as the runtime player identifier). Returns
* a zero-filled record when the lookup fails — boot state, an
* incomplete report, or a future schema bump that switches to
* UUIDs. Wrapping the lookup in one helper keeps the migration
* cost contained.
*/
function findLocalPlayerDrive(report: Report, raceName: string): number {
if (raceName === "") return 0;
function findLocalPlayerTech(
report: Report,
raceName: string,
): LocalPlayerTech {
if (raceName === "") return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
for (let i = 0; i < report.playerLength(); i++) {
const player = report.player(i);
if (player === null) continue;
if ((player.name() ?? "") !== raceName) continue;
return player.drive();
return {
drive: player.drive(),
weapons: player.weapons(),
shields: player.shields(),
cargo: player.cargo(),
};
}
return 0;
return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
}
/**
@@ -18,9 +18,12 @@ Phase 17 ship-class designer. Two modes driven by the optional
referenced by active production / ship groups) and a Back
button.
Phase 18 wires `pkg/calc/` into the form for live mass / speed /
range / cargo previews; the markup keeps a placeholder slot near
the value fields so the diff in Phase 18 stays minimal.
Phase 18 wires `pkg/calc/` (via the `Core` WASM bridge) into the
new-mode form: an `<aside class="preview">` block recomputes mass,
full-load mass, max speed, range at full load, and cargo capacity
on every form change, using the local player's tech levels off the
rendered report. The preview hides itself until the form passes
validation, so it never displays half-cooked numbers.
-->
<script lang="ts">
import { getContext, tick } from "svelte";
@@ -41,6 +44,10 @@ the value fields so the diff in Phase 18 stays minimal.
validateShipClass,
type ShipClassInvalidReason,
} from "$lib/util/ship-class-validation";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
@@ -48,6 +55,7 @@ the value fields so the diff in Phase 18 stays minimal.
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const gameId = $derived(page.params.id ?? "");
const classId = $derived(page.params.classId ?? "");
@@ -105,6 +113,54 @@ the value fields so the diff in Phase 18 stays minimal.
);
const canSave = $derived(validation.ok && draft !== undefined);
const driveTech = $derived(rendered?.report?.localPlayerDrive ?? 0);
const cargoTech = $derived(rendered?.report?.localPlayerCargo ?? 0);
interface PreviewValues {
mass: number;
fullLoadMass: number;
maxSpeed: number;
rangeAtFull: number;
cargoCapacity: number;
}
const preview = $derived.by<PreviewValues | null>(() => {
const core = coreHandle?.core;
if (core === undefined || core === null) return null;
if (!validation.ok) return null;
const v = validation.value;
const mass = core.emptyMass({
drive: v.drive,
weapons: v.weapons,
armament: v.armament,
shields: v.shields,
cargo: v.cargo,
});
if (mass === null) return null;
const cargoCapacity = core.cargoCapacity({
cargo: v.cargo,
cargoTech,
});
const carryAtFull =
cargoTech > 0
? core.carryingMass({ load: cargoCapacity, cargoTech })
: 0;
const fullLoadMass = core.fullMass({
emptyMass: mass,
carryingMass: carryAtFull,
});
const driveEffective = core.driveEffective({
drive: v.drive,
driveTech,
});
const maxSpeed = core.speed({ driveEffective, fullMass: mass });
const rangeAtFull = core.speed({
driveEffective,
fullMass: fullLoadMass,
});
return { mass, fullLoadMass, maxSpeed, rangeAtFull, cargoCapacity };
});
$effect(() => {
if (!isViewMode) {
void tick().then(() => nameInputEl?.focus());
@@ -309,6 +365,52 @@ the value fields so the diff in Phase 18 stays minimal.
{invalidMessage}
</p>
{/if}
{#if preview !== null}
<aside
class="preview"
data-testid="designer-ship-class-preview"
>
<h3>{i18n.t("game.designer.ship_class.preview.title")}</h3>
<dl>
<div class="row">
<dt>{i18n.t("game.designer.ship_class.preview.mass")}</dt>
<dd data-testid="designer-ship-class-preview-mass">
{formatNumber(preview.mass)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.full_load_mass")}
</dt>
<dd data-testid="designer-ship-class-preview-full-load-mass">
{formatNumber(preview.fullLoadMass)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.max_speed")}
</dt>
<dd data-testid="designer-ship-class-preview-max-speed">
{formatNumber(preview.maxSpeed)}
</dd>
</div>
<div class="row">
<dt>{i18n.t("game.designer.ship_class.preview.range")}</dt>
<dd data-testid="designer-ship-class-preview-range">
{formatNumber(preview.rangeAtFull)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.cargo_capacity")}
</dt>
<dd data-testid="designer-ship-class-preview-cargo-capacity">
{formatNumber(preview.cargoCapacity)}
</dd>
</div>
</dl>
</aside>
{/if}
<div class="actions">
<button
type="button"
@@ -383,6 +485,42 @@ the value fields so the diff in Phase 18 stays minimal.
font-size: 0.8rem;
color: #d97a7a;
}
.preview {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem 0.85rem;
background: #0a0e1a;
border: 1px solid #2a3150;
border-radius: 4px;
max-width: 30rem;
}
.preview h3 {
margin: 0;
font-size: 0.85rem;
color: #aab;
font-weight: 500;
}
.preview dl {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.2rem;
column-gap: 0.75rem;
}
.preview .row {
display: contents;
}
.preview dt {
color: #aab;
font-size: 0.85rem;
}
.preview dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
text-align: right;
}
.fields {
margin: 0;
display: grid;
@@ -0,0 +1,31 @@
// Exposes the WASM `Core` instance through a Svelte context so views
// that need its math bridge (Phase 18 ship-class preview, future
// inspector calculators) can read it without re-booting the module.
// The layout populates `core` after `loadCore()` resolves; consumers
// observe `null` while the boot is in flight and the live `Core`
// once the runtime is ready.
import type { Core } from "../platform/core/index";
/**
* CORE_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its booted `Core` to descendants such as
* the ship-class designer preview pane.
*/
export const CORE_CONTEXT_KEY = Symbol("core");
export interface CoreHandle {
readonly core: Core | null;
}
export class CoreHolder implements CoreHandle {
#core: Core | null = $state(null);
get core(): Core | null {
return this.#core;
}
set(core: Core | null): void {
this.#core = core;
}
}
+7
View File
@@ -238,6 +238,13 @@ const en = {
"game.designer.ship_class.invalid.cargo_value": "cargo must be 0 or ≥ 1",
"game.designer.ship_class.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
"game.designer.ship_class.invalid.all_zero": "at least one value must be nonzero",
"game.designer.ship_class.preview.title": "preview at your tech levels",
"game.designer.ship_class.preview.mass": "mass",
"game.designer.ship_class.preview.full_load_mass": "full-load mass",
"game.designer.ship_class.preview.max_speed": "max speed (ly/turn)",
"game.designer.ship_class.preview.range": "range at full load (ly/turn)",
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
"game.designer.ship_class.preview.unavailable": "—",
} as const;
export default en;
+7
View File
@@ -239,6 +239,13 @@ const ru: Record<keyof typeof en, string> = {
"game.designer.ship_class.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
"game.designer.ship_class.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
"game.designer.ship_class.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
"game.designer.ship_class.preview.title": "превью при ваших технологиях",
"game.designer.ship_class.preview.mass": "масса",
"game.designer.ship_class.preview.full_load_mass": "масса с полной загрузкой",
"game.designer.ship_class.preview.max_speed": "максимальная скорость (св.лет/ход)",
"game.designer.ship_class.preview.range": "дальность при полной загрузке (св.лет/ход)",
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
"game.designer.ship_class.preview.unavailable": "—",
};
export default ru;
+86
View File
@@ -35,6 +35,44 @@ export interface EventSigningFields {
payloadHash: Uint8Array;
}
export interface DriveEffectiveInput {
drive: number;
driveTech: number;
}
export interface ShipBlocksInput {
drive: number;
weapons: number;
armament: number;
shields: number;
cargo: number;
}
export interface WeaponsBlockInput {
weapons: number;
armament: number;
}
export interface FullMassInput {
emptyMass: number;
carryingMass: number;
}
export interface SpeedInput {
driveEffective: number;
fullMass: number;
}
export interface CargoCapacityInput {
cargo: number;
cargoTech: number;
}
export interface CarryingMassInput {
load: number;
cargoTech: number;
}
export interface Core {
/**
* signRequest returns the canonical signing input bytes for a v1
@@ -71,6 +109,54 @@ export interface Core {
payloadBytes: Uint8Array,
payloadHash: Uint8Array,
): boolean;
/**
* driveEffective wraps `pkg/calc/ship.go.DriveEffective`: effective
* drive power = ship drive block × player drive tech.
*/
driveEffective(input: DriveEffectiveInput): number;
/**
* emptyMass wraps `pkg/calc/ship.go.EmptyMass`: mass of the ship
* with empty holds. Returns null when the upstream validator
* rejects the weapons/armament pair (one zero and the other
* non-zero).
*/
emptyMass(input: ShipBlocksInput): number | null;
/**
* weaponsBlockMass wraps `pkg/calc/ship.go.WeaponsBlockMass`: mass
* of the weapons sub-block. Returns null on the same invalid
* pairing as emptyMass.
*/
weaponsBlockMass(input: WeaponsBlockInput): number | null;
/**
* fullMass wraps `pkg/calc/ship.go.FullMass`: empty mass plus the
* mass of the carried cargo.
*/
fullMass(input: FullMassInput): number;
/**
* speed wraps `pkg/calc/ship.go.Speed`: light-years per turn,
* driveEffective × 20 / fullMass; zero when fullMass ≤ 0.
*/
speed(input: SpeedInput): number;
/**
* cargoCapacity wraps `pkg/calc/ship.go.CargoCapacity`: hold
* capacity of one ship in cargo units, scaled by the player's
* cargo tech.
*/
cargoCapacity(input: CargoCapacityInput): number;
/**
* carryingMass wraps `pkg/calc/ship.go.CarryingMass`: mass of a
* payload of `load` cargo units at the player's cargo tech. Used
* by the ship-class designer to derive full-load mass from
* cargoCapacity.
*/
carryingMass(input: CarryingMassInput): number;
}
export type CoreLoader = () => Promise<Core>;
+35
View File
@@ -9,10 +9,17 @@
// served from `static/core.wasm`.
import type {
CargoCapacityInput,
CarryingMassInput,
Core,
DriveEffectiveInput,
EventSigningFields,
FullMassInput,
RequestSigningFields,
ResponseSigningFields,
ShipBlocksInput,
SpeedInput,
WeaponsBlockInput,
} from "./index";
/**
@@ -36,6 +43,13 @@ interface GalaxyCoreBridge {
payloadBytes: Uint8Array,
payloadHash: Uint8Array,
): boolean;
driveEffective(input: DriveEffectiveInput): number;
emptyMass(input: ShipBlocksInput): number | null;
weaponsBlockMass(input: WeaponsBlockInput): number | null;
fullMass(input: FullMassInput): number;
speed(input: SpeedInput): number;
cargoCapacity(input: CargoCapacityInput): number;
carryingMass(input: CarryingMassInput): number;
}
interface BridgeRequestFields {
@@ -175,6 +189,27 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
): boolean {
return bridge.verifyPayloadHash(payloadBytes, payloadHash);
},
driveEffective(input: DriveEffectiveInput): number {
return bridge.driveEffective(input);
},
emptyMass(input: ShipBlocksInput): number | null {
return bridge.emptyMass(input);
},
weaponsBlockMass(input: WeaponsBlockInput): number | null {
return bridge.weaponsBlockMass(input);
},
fullMass(input: FullMassInput): number {
return bridge.fullMass(input);
},
speed(input: SpeedInput): number {
return bridge.speed(input);
},
cargoCapacity(input: CargoCapacityInput): number {
return bridge.cargoCapacity(input);
},
carryingMass(input: CarryingMassInput): number {
return bridge.carryingMass(input);
},
};
}
@@ -73,6 +73,10 @@ fresh.
GALAXY_CLIENT_CONTEXT_KEY,
GalaxyClientHolder,
} from "$lib/galaxy-client-context.svelte";
import {
CORE_CONTEXT_KEY,
CoreHolder,
} from "$lib/core-context.svelte";
import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index";
import { loadCore } from "../../../platform/core/index";
@@ -105,6 +109,8 @@ fresh.
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
const galaxyClient = new GalaxyClientHolder();
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
const coreHolder = new CoreHolder();
setContext(CORE_CONTEXT_KEY, coreHolder);
// `MapPickService` lives at the layout so both the active map
// view (which binds the renderer-side resolver) and the
// inspector subsections (which call `pick(...)`) see the same
@@ -172,6 +178,7 @@ fresh.
const deviceSessionId = session.deviceSessionId;
try {
const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
coreHolder.set(core);
const client = new GalaxyClient({
core,
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
Binary file not shown.
@@ -25,6 +25,12 @@ import {
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "../src/lib/core-context.svelte";
import { loadWasmCoreForTest } from "./setup-wasm";
import type { Core } from "../src/platform/core/index";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
@@ -100,21 +106,27 @@ function makeReport(localShipClass: ShipClassSummary[] = []): GameReport {
localShipClass,
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
};
}
function mountDesigner(opts: {
classId?: string;
report?: GameReport | null;
core?: Core | null;
}) {
const report = opts.report ?? makeReport();
pageMock.params = opts.classId
? { id: "g1", classId: opts.classId }
: { id: "g1" };
const renderedReport = { get report() { return report; } };
const coreHandle: CoreHandle = { core: opts.core ?? null };
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
[CORE_CONTEXT_KEY, coreHandle],
]);
return render(DesignerShipClass, { context });
}
@@ -260,3 +272,136 @@ describe("ship-class designer (view mode)", () => {
).toHaveTextContent("Ghost");
});
});
describe("ship-class designer preview pane (Phase 18)", () => {
test("hides preview while validation fails", () => {
const ui = mountDesigner({});
expect(
ui.queryByTestId("designer-ship-class-preview"),
).not.toBeInTheDocument();
});
test("hides preview when no Core is provided", async () => {
const ui = mountDesigner({});
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
target: { value: "Drone" },
});
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
target: { value: "1" },
});
await waitFor(() =>
expect(ui.getByTestId("designer-ship-class-save")).not.toBeDisabled(),
);
expect(
ui.queryByTestId("designer-ship-class-preview"),
).not.toBeInTheDocument();
});
test("renders five rows once form is valid and Core is ready", async () => {
const core = await loadWasmCoreForTest();
const report: GameReport = {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 1.5,
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 1.2,
};
const ui = mountDesigner({ report, core });
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
target: { value: "Cruiser" },
});
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
target: { value: "8" },
});
await fireEvent.input(
ui.getByTestId("designer-ship-class-input-armament"),
{ target: { value: "2" } },
);
await fireEvent.input(ui.getByTestId("designer-ship-class-input-weapons"), {
target: { value: "5" },
});
await fireEvent.input(ui.getByTestId("designer-ship-class-input-shields"), {
target: { value: "3" },
});
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
target: { value: "4" },
});
await waitFor(() =>
expect(
ui.getByTestId("designer-ship-class-preview"),
).toBeInTheDocument(),
);
// Empty mass = drive + shields + cargo + (armament+1)*(weapons/2)
// = 8 + 3 + 4 + 3 * 2.5 = 22.5
expect(
ui.getByTestId("designer-ship-class-preview-mass"),
).toHaveTextContent("22.5");
// CargoCapacity = cargoTech * (cargo + cargo²/20)
// = 1.2 * (4 + 16/20) = 1.2 * 4.8 = 5.76
expect(
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
).toHaveTextContent("5.76");
// CarryingMass at full = capacity / cargoTech = 5.76 / 1.2 = 4.8
// FullLoadMass = 22.5 + 4.8 = 27.3
expect(
ui.getByTestId("designer-ship-class-preview-full-load-mass"),
).toHaveTextContent("27.3");
// DriveEffective = 8 * 1.5 = 12
// MaxSpeed = 12 * 20 / 22.5 = 10.666… → "10.67"
expect(
ui.getByTestId("designer-ship-class-preview-max-speed"),
).toHaveTextContent("10.67");
// RangeAtFull = 12 * 20 / 27.3 = 8.791… → "8.79"
expect(
ui.getByTestId("designer-ship-class-preview-range"),
).toHaveTextContent("8.79");
});
test("preview reacts to subsequent edits", async () => {
const core = await loadWasmCoreForTest();
const report: GameReport = {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 1,
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 1,
};
const ui = mountDesigner({ report, core });
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
target: { value: "Hauler" },
});
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
target: { value: "1" },
});
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
target: { value: "5" },
});
await waitFor(() =>
expect(
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
).toHaveTextContent("6.25"),
);
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
target: { value: "10" },
});
await waitFor(() =>
expect(
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
).toHaveTextContent("15"),
);
});
});
+9
View File
@@ -208,5 +208,14 @@ function mockCore(opts: MockCoreOptions): Core & {
verifyResponse: vi.fn(opts.verifyResponseImpl),
verifyEvent: vi.fn(() => true),
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
// `GalaxyClient` does not exercise the Phase 18 calc bridge,
// so these stubs only need to satisfy the `Core` interface.
driveEffective: () => 0,
emptyMass: () => 0,
weaponsBlockMass: () => 0,
fullMass: () => 0,
speed: () => 0,
cargoCapacity: () => 0,
carryingMass: () => 0,
};
}
@@ -42,6 +42,9 @@ function withGameState(opts: {
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
};
store.status = "ready";
}
@@ -76,6 +76,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
};
}
@@ -83,6 +83,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
};
}
@@ -58,6 +58,9 @@ function makeReport(
localShipClass: [],
routes: [{ sourcePlanetNumber: source, entries }],
localPlayerDrive: 1,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
};
}
@@ -199,6 +202,9 @@ describe("buildCargoRouteLines", () => {
localShipClass: [],
routes: [],
localPlayerDrive: 1,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
};
expect(buildCargoRouteLines(report)).toEqual([]);
});
+3
View File
@@ -50,6 +50,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
};
}
+3
View File
@@ -23,6 +23,9 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...overrides,
};
}
@@ -98,6 +98,9 @@ function makeReport(localShipClass: ShipClassSummary[]): GameReport {
localShipClass,
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
};
}
+115 -3
View File
@@ -14,14 +14,26 @@
// - 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
//
// 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 boolean, or fail closed.
// They never throw — callers may inspect the boolean result or rely on
// the canon-byte length to detect malformed input.
// 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
@@ -30,6 +42,7 @@ package main
import (
"syscall/js"
"galaxy/core/calc"
"galaxy/core/canon"
)
@@ -39,6 +52,13 @@ func main() {
"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),
}))
// Block forever so the Go runtime stays alive while JS keeps calling
@@ -112,6 +132,98 @@ func verifyPayloadHash(_ js.Value, args []js.Value) any {
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))
}
// 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