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:
+42
-21
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user