ui: plan 01-27 done #1
+42
-21
@@ -2012,28 +2012,44 @@ Targeted tests:
|
|||||||
class, list it, delete it; rejected-submit kept; field-validation
|
class, list it, delete it; rejected-submit kept; field-validation
|
||||||
kept (Save disabled with localised tooltip).
|
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,
|
Goal: wire `pkg/calc/` ship math into the designer for live mass,
|
||||||
speed, range, and cargo capacity previews.
|
speed, range, and cargo capacity previews.
|
||||||
|
|
||||||
Artifacts:
|
Artifacts:
|
||||||
|
|
||||||
- `ui/core/calc/ship.go` thin Go bridge wrapping `pkg/calc/.FullMass`,
|
- `ui/core/calc/ship.go` thin Go bridge wrapping seven functions
|
||||||
`EmptyMass`, `Speed`, `CargoCapacity`, `WeaponsBlockMass`,
|
from `pkg/calc/ship.go` — `DriveEffective`, `EmptyMass`,
|
||||||
`DriveEffective` in JSON-marshallable signatures, exported through
|
`WeaponsBlockMass`, `FullMass`, `Speed`, `CargoCapacity`,
|
||||||
the `Core` API
|
`CarryingMass` — each as a one-line passthrough; the seventh
|
||||||
- `ui/frontend/src/platform/core/index.ts` extends `Core` interface
|
function (`CarryingMass`) was added during stage implementation
|
||||||
with the new calc methods
|
to let the preview compose `full-load mass` from `CargoCapacity`
|
||||||
- live-updating preview pane in the ship-class designer showing mass,
|
without injecting math into TS;
|
||||||
full-load mass, max speed, range, and cargo capacity at the player's
|
- `ui/wasm/main.go` registers the seven wrappers under
|
||||||
current tech levels
|
`globalThis.galaxyCore`; `ui/frontend/src/platform/core/index.ts`
|
||||||
- audit step recorded in `ui/docs/calc-bridge.md`: every wired
|
extends `Core` with the matching typed methods (`emptyMass` and
|
||||||
function listed against its `pkg/calc/` source
|
`weaponsBlockMass` return `number | null`, mirroring the Go
|
||||||
- if any required `pkg/calc/` function is missing, this phase raises a
|
`(_, false)` validator path);
|
||||||
blocker and the function is added to `pkg/calc/` first (owner-led)
|
- `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.
|
Dependencies: Phases 5 (Core skeleton), 17.
|
||||||
|
|
||||||
@@ -2047,11 +2063,14 @@ Acceptance criteria:
|
|||||||
|
|
||||||
Targeted tests:
|
Targeted tests:
|
||||||
|
|
||||||
- Go parity tests in `ui/core/calc/` against `pkg/calc/` outputs on
|
- Go parity tests in `ui/core/calc/ship_test.go` against `pkg/calc/`
|
||||||
shared fixtures;
|
outputs on shared fixtures, plus a composition test that exercises
|
||||||
- Vitest snapshot tests for the preview pane on canonical inputs;
|
the exact preview pipeline (empty → cargo capacity → carrying mass
|
||||||
- Playwright e2e: edit a ship class, observe preview updates and
|
→ full-load mass → speed at empty + at full);
|
||||||
submit, confirm server-side mass matches.
|
- 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)
|
## Phase 19. Inspector — Ship Group (Read-Only)
|
||||||
|
|
||||||
@@ -2112,7 +2131,9 @@ Artifacts:
|
|||||||
`SendGroup`, `LoadCargo`, `UnloadCargo`, `Modernize`, `Dismantle`,
|
`SendGroup`, `LoadCargo`, `UnloadCargo`, `Modernize`, `Dismantle`,
|
||||||
`TransferToRace`, `AssignToFleet` command variants
|
`TransferToRace`, `AssignToFleet` command variants
|
||||||
- `Send` action picks destination through a planet picker filtered by
|
- `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
|
- `Modernize` cost preview using `pkg/calc/` formula via Core
|
||||||
- confirmation dialog for `Dismantle` over a foreign planet with
|
- confirmation dialog for `Dismantle` over a foreign planet with
|
||||||
colonists onboard (special-case from [`rules.txt`](../game/rules.txt): colonists die)
|
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
|
```text
|
||||||
ui/core/
|
ui/core/
|
||||||
├── go.mod module galaxy/core (Go 1.26.0)
|
├── 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/ canonical-bytes builders and verifiers
|
||||||
│ ├── canon.go length-prefix helpers
|
│ ├── canon.go length-prefix helpers
|
||||||
│ ├── request.go galaxy-request-v1 fields and signing input
|
│ ├── request.go galaxy-request-v1 fields and signing input
|
||||||
@@ -88,6 +90,23 @@ ui/core/
|
|||||||
- Sentinel errors: `ErrInvalidPrivateKey`, `ErrInvalidPublicKey`,
|
- Sentinel errors: `ErrInvalidPrivateKey`, `ErrInvalidPublicKey`,
|
||||||
`ErrInvalidPublicKeyEncoding`.
|
`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`
|
### `galaxy/core/types`
|
||||||
|
|
||||||
- `RequestEnvelope`, `ResponseEnvelope`, `EventEnvelope` — full Go
|
- `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
|
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 (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
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
|
potential, forecast output for a chosen production type, ship build
|
||||||
progress, tech progress) that depend on the same formulas the engine
|
progress, tech progress) that depend on the same formulas the engine
|
||||||
uses at turn cutoff. To keep one source of truth, those formulas live
|
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
|
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
|
Phase 18 lands the **ship-math slice** of the bridge — everything
|
||||||
what it must expose, what is already in place, and what is missing.
|
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
|
## Current `pkg/calc/` exports
|
||||||
|
|
||||||
@@ -32,6 +78,7 @@ whether the underlying Go function exists.
|
|||||||
|
|
||||||
| UI feature | Go formula | In `pkg/calc/`? | Surfaced to TS? |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
duplicates `flightDistance` knowingly — same precedent as the
|
||||||
production forecast deferral above.
|
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
|
1. Promote any still-engine-only formula from the table above into
|
||||||
engine and the UI share one Go-side implementation. The engine
|
`pkg/calc/` so the engine and the UI share one Go-side
|
||||||
continues to call them through `game/internal/...` wrappers.
|
implementation. The engine continues to call them through its
|
||||||
2. Mount a `ui/core/calc/` Go module that re-exports the subset the
|
`game/internal/...` wrappers.
|
||||||
UI needs. Keep it WASM-friendly (no `unsafe`, no goroutines,
|
2. Add a thin one-line wrapper in `ui/core/calc/` (new file per
|
||||||
simple in/out values).
|
topic, e.g. `ui/core/calc/planet.go` for production forecasts).
|
||||||
3. Wire the WASM glue in `ui/wasm/main.go` so each calc function is
|
No math in the bridge.
|
||||||
reachable from `globalThis.galaxyCore`.
|
3. Register the function in `ui/wasm/main.go` under
|
||||||
4. Add a TypeScript adapter under `ui/frontend/src/platform/core/`
|
`globalThis.galaxyCore`.
|
||||||
that wraps the WASM calls in typed helpers
|
4. Extend the `Core` interface in
|
||||||
(`forecastIndustry(freeProduction, …)` etc.).
|
`ui/frontend/src/platform/core/index.ts` with a typed signature
|
||||||
5. Update this document with the live function inventory and
|
and add the passthrough in `wasm.ts.adaptBridge`.
|
||||||
delete the "missing" rows above.
|
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[];
|
routes: ReportRoute[];
|
||||||
/**
|
/**
|
||||||
* localPlayerDrive is the local player's drive tech level. The
|
* localPlayerDrive, localPlayerWeapons, localPlayerShields,
|
||||||
* engine's reach formula is `40 * driveTech`
|
* localPlayerCargo carry the local player's four tech levels,
|
||||||
* (`game/internal/model/game/race.go.FlightDistance`); the
|
* read from the matching `Player` row in the report. Drive
|
||||||
* cargo-route picker filters destinations through it, so the
|
* powers reach (`40 * driveTech`,
|
||||||
* value is propagated all the way through `applyOrderOverlay`
|
* `game/internal/model/game/race.go.FlightDistance`) and the
|
||||||
* to the inspector subsection. Zero on boot or when the
|
* cargo-route picker; cargo feeds the ship-class designer's
|
||||||
* report's player block is missing the local entry.
|
* 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;
|
localPlayerDrive: number;
|
||||||
|
localPlayerWeapons: number;
|
||||||
|
localPlayerShields: number;
|
||||||
|
localPlayerCargo: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGameReport(
|
export async function fetchGameReport(
|
||||||
@@ -290,7 +298,7 @@ function decodeReport(report: Report): GameReport {
|
|||||||
|
|
||||||
const raceName = report.race() ?? "";
|
const raceName = report.race() ?? "";
|
||||||
const routes = decodeReportRoutes(report);
|
const routes = decodeReportRoutes(report);
|
||||||
const localPlayerDrive = findLocalPlayerDrive(report, raceName);
|
const localTech = findLocalPlayerTech(report, raceName);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
turn: Number(report.turn()),
|
turn: Number(report.turn()),
|
||||||
@@ -301,7 +309,10 @@ function decodeReport(report: Report): GameReport {
|
|||||||
race: raceName,
|
race: raceName,
|
||||||
localShipClass,
|
localShipClass,
|
||||||
routes,
|
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];
|
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
|
* findLocalPlayerTech locates the local player's four tech levels
|
||||||
* level by matching `Player.name` against the report's `race`
|
* by matching `Player.name` against the report's `race` field (the
|
||||||
* field (the engine uses race name as the runtime player
|
* engine uses race name as the runtime player identifier). Returns
|
||||||
* identifier). Returns 0 when the lookup fails — boot state, an
|
* a zero-filled record when the lookup fails — boot state, an
|
||||||
* incomplete report, or a future schema bump that switches to
|
* incomplete report, or a future schema bump that switches to
|
||||||
* UUIDs. Wrapping the lookup in one helper keeps the migration
|
* UUIDs. Wrapping the lookup in one helper keeps the migration
|
||||||
* cost contained.
|
* cost contained.
|
||||||
*/
|
*/
|
||||||
function findLocalPlayerDrive(report: Report, raceName: string): number {
|
function findLocalPlayerTech(
|
||||||
if (raceName === "") return 0;
|
report: Report,
|
||||||
|
raceName: string,
|
||||||
|
): LocalPlayerTech {
|
||||||
|
if (raceName === "") return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
|
||||||
for (let i = 0; i < report.playerLength(); i++) {
|
for (let i = 0; i < report.playerLength(); i++) {
|
||||||
const player = report.player(i);
|
const player = report.player(i);
|
||||||
if (player === null) continue;
|
if (player === null) continue;
|
||||||
if ((player.name() ?? "") !== raceName) 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
|
referenced by active production / ship groups) and a Back
|
||||||
button.
|
button.
|
||||||
|
|
||||||
Phase 18 wires `pkg/calc/` into the form for live mass / speed /
|
Phase 18 wires `pkg/calc/` (via the `Core` WASM bridge) into the
|
||||||
range / cargo previews; the markup keeps a placeholder slot near
|
new-mode form: an `<aside class="preview">` block recomputes mass,
|
||||||
the value fields so the diff in Phase 18 stays minimal.
|
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">
|
<script lang="ts">
|
||||||
import { getContext, tick } from "svelte";
|
import { getContext, tick } from "svelte";
|
||||||
@@ -41,6 +44,10 @@ the value fields so the diff in Phase 18 stays minimal.
|
|||||||
validateShipClass,
|
validateShipClass,
|
||||||
type ShipClassInvalidReason,
|
type ShipClassInvalidReason,
|
||||||
} from "$lib/util/ship-class-validation";
|
} from "$lib/util/ship-class-validation";
|
||||||
|
import {
|
||||||
|
CORE_CONTEXT_KEY,
|
||||||
|
type CoreHandle,
|
||||||
|
} from "$lib/core-context.svelte";
|
||||||
|
|
||||||
const rendered = getContext<RenderedReportSource | undefined>(
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
@@ -48,6 +55,7 @@ the value fields so the diff in Phase 18 stays minimal.
|
|||||||
const draft = getContext<OrderDraftStore | undefined>(
|
const draft = getContext<OrderDraftStore | undefined>(
|
||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
|
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
const gameId = $derived(page.params.id ?? "");
|
||||||
const classId = $derived(page.params.classId ?? "");
|
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 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(() => {
|
$effect(() => {
|
||||||
if (!isViewMode) {
|
if (!isViewMode) {
|
||||||
void tick().then(() => nameInputEl?.focus());
|
void tick().then(() => nameInputEl?.focus());
|
||||||
@@ -309,6 +365,52 @@ the value fields so the diff in Phase 18 stays minimal.
|
|||||||
{invalidMessage}
|
{invalidMessage}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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">
|
<div class="actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -383,6 +485,42 @@ the value fields so the diff in Phase 18 stays minimal.
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #d97a7a;
|
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 {
|
.fields {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
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.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.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.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;
|
} as const;
|
||||||
|
|
||||||
export default en;
|
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.cargo_value": "трюм должен быть 0 или ≥ 1",
|
||||||
"game.designer.ship_class.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
|
"game.designer.ship_class.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
|
||||||
"game.designer.ship_class.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
"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;
|
export default ru;
|
||||||
|
|||||||
@@ -35,6 +35,44 @@ export interface EventSigningFields {
|
|||||||
payloadHash: Uint8Array;
|
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 {
|
export interface Core {
|
||||||
/**
|
/**
|
||||||
* signRequest returns the canonical signing input bytes for a v1
|
* signRequest returns the canonical signing input bytes for a v1
|
||||||
@@ -71,6 +109,54 @@ export interface Core {
|
|||||||
payloadBytes: Uint8Array,
|
payloadBytes: Uint8Array,
|
||||||
payloadHash: Uint8Array,
|
payloadHash: Uint8Array,
|
||||||
): boolean;
|
): 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>;
|
export type CoreLoader = () => Promise<Core>;
|
||||||
|
|||||||
@@ -9,10 +9,17 @@
|
|||||||
// served from `static/core.wasm`.
|
// served from `static/core.wasm`.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CargoCapacityInput,
|
||||||
|
CarryingMassInput,
|
||||||
Core,
|
Core,
|
||||||
|
DriveEffectiveInput,
|
||||||
EventSigningFields,
|
EventSigningFields,
|
||||||
|
FullMassInput,
|
||||||
RequestSigningFields,
|
RequestSigningFields,
|
||||||
ResponseSigningFields,
|
ResponseSigningFields,
|
||||||
|
ShipBlocksInput,
|
||||||
|
SpeedInput,
|
||||||
|
WeaponsBlockInput,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +43,13 @@ interface GalaxyCoreBridge {
|
|||||||
payloadBytes: Uint8Array,
|
payloadBytes: Uint8Array,
|
||||||
payloadHash: Uint8Array,
|
payloadHash: Uint8Array,
|
||||||
): boolean;
|
): 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 {
|
interface BridgeRequestFields {
|
||||||
@@ -175,6 +189,27 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
|
|||||||
): boolean {
|
): boolean {
|
||||||
return bridge.verifyPayloadHash(payloadBytes, payloadHash);
|
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,
|
GALAXY_CLIENT_CONTEXT_KEY,
|
||||||
GalaxyClientHolder,
|
GalaxyClientHolder,
|
||||||
} from "$lib/galaxy-client-context.svelte";
|
} from "$lib/galaxy-client-context.svelte";
|
||||||
|
import {
|
||||||
|
CORE_CONTEXT_KEY,
|
||||||
|
CoreHolder,
|
||||||
|
} from "$lib/core-context.svelte";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
import { loadStore } from "../../../platform/store/index";
|
import { loadStore } from "../../../platform/store/index";
|
||||||
import { loadCore } from "../../../platform/core/index";
|
import { loadCore } from "../../../platform/core/index";
|
||||||
@@ -105,6 +109,8 @@ fresh.
|
|||||||
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
|
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
|
||||||
const galaxyClient = new GalaxyClientHolder();
|
const galaxyClient = new GalaxyClientHolder();
|
||||||
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
|
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
|
// `MapPickService` lives at the layout so both the active map
|
||||||
// view (which binds the renderer-side resolver) and the
|
// view (which binds the renderer-side resolver) and the
|
||||||
// inspector subsections (which call `pick(...)`) see the same
|
// inspector subsections (which call `pick(...)`) see the same
|
||||||
@@ -172,6 +178,7 @@ fresh.
|
|||||||
const deviceSessionId = session.deviceSessionId;
|
const deviceSessionId = session.deviceSessionId;
|
||||||
try {
|
try {
|
||||||
const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
|
const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
|
||||||
|
coreHolder.set(core);
|
||||||
const client = new GalaxyClient({
|
const client = new GalaxyClient({
|
||||||
core,
|
core,
|
||||||
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
|
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
|
||||||
|
|||||||
Binary file not shown.
@@ -25,6 +25,12 @@ import {
|
|||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../src/sync/order-draft.svelte";
|
} from "../src/sync/order-draft.svelte";
|
||||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.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 { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||||
import type { Cache } from "../src/platform/store/index";
|
import type { Cache } from "../src/platform/store/index";
|
||||||
@@ -100,21 +106,27 @@ function makeReport(localShipClass: ShipClassSummary[] = []): GameReport {
|
|||||||
localShipClass,
|
localShipClass,
|
||||||
routes: [],
|
routes: [],
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountDesigner(opts: {
|
function mountDesigner(opts: {
|
||||||
classId?: string;
|
classId?: string;
|
||||||
report?: GameReport | null;
|
report?: GameReport | null;
|
||||||
|
core?: Core | null;
|
||||||
}) {
|
}) {
|
||||||
const report = opts.report ?? makeReport();
|
const report = opts.report ?? makeReport();
|
||||||
pageMock.params = opts.classId
|
pageMock.params = opts.classId
|
||||||
? { id: "g1", classId: opts.classId }
|
? { id: "g1", classId: opts.classId }
|
||||||
: { id: "g1" };
|
: { id: "g1" };
|
||||||
const renderedReport = { get report() { return report; } };
|
const renderedReport = { get report() { return report; } };
|
||||||
|
const coreHandle: CoreHandle = { core: opts.core ?? null };
|
||||||
const context = new Map<unknown, unknown>([
|
const context = new Map<unknown, unknown>([
|
||||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||||
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||||
|
[CORE_CONTEXT_KEY, coreHandle],
|
||||||
]);
|
]);
|
||||||
return render(DesignerShipClass, { context });
|
return render(DesignerShipClass, { context });
|
||||||
}
|
}
|
||||||
@@ -260,3 +272,136 @@ describe("ship-class designer (view mode)", () => {
|
|||||||
).toHaveTextContent("Ghost");
|
).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),
|
verifyResponse: vi.fn(opts.verifyResponseImpl),
|
||||||
verifyEvent: vi.fn(() => true),
|
verifyEvent: vi.fn(() => true),
|
||||||
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
|
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: [],
|
localShipClass: [],
|
||||||
routes: [],
|
routes: [],
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
};
|
};
|
||||||
store.status = "ready";
|
store.status = "ready";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
localShipClass: [],
|
localShipClass: [],
|
||||||
routes: [],
|
routes: [],
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
localShipClass: [],
|
localShipClass: [],
|
||||||
routes: [],
|
routes: [],
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ function makeReport(
|
|||||||
localShipClass: [],
|
localShipClass: [],
|
||||||
routes: [{ sourcePlanetNumber: source, entries }],
|
routes: [{ sourcePlanetNumber: source, entries }],
|
||||||
localPlayerDrive: 1,
|
localPlayerDrive: 1,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +202,9 @@ describe("buildCargoRouteLines", () => {
|
|||||||
localShipClass: [],
|
localShipClass: [],
|
||||||
routes: [],
|
routes: [],
|
||||||
localPlayerDrive: 1,
|
localPlayerDrive: 1,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
};
|
};
|
||||||
expect(buildCargoRouteLines(report)).toEqual([]);
|
expect(buildCargoRouteLines(report)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
localShipClass: [],
|
localShipClass: [],
|
||||||
routes: [],
|
routes: [],
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
|||||||
localShipClass: [],
|
localShipClass: [],
|
||||||
routes: [],
|
routes: [],
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ function makeReport(localShipClass: ShipClassSummary[]): GameReport {
|
|||||||
localShipClass,
|
localShipClass,
|
||||||
routes: [],
|
routes: [],
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-3
@@ -14,14 +14,26 @@
|
|||||||
// - verifyEvent(publicKey, signature, fields) -> boolean
|
// - verifyEvent(publicKey, signature, fields) -> boolean
|
||||||
// - verifyPayloadHash(payloadBytes, payloadHash) -> 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
|
// Field objects are plain JS objects with camelCase keys matching the
|
||||||
// TypeScript `Core` interface, and bytes fields are Uint8Array.
|
// TypeScript `Core` interface, and bytes fields are Uint8Array.
|
||||||
// Timestamps are JS Number (Unix milliseconds fit in 53 bits well past
|
// Timestamps are JS Number (Unix milliseconds fit in 53 bits well past
|
||||||
// year 2200).
|
// year 2200).
|
||||||
//
|
//
|
||||||
// All functions return either a Uint8Array, a boolean, or fail closed.
|
// All functions return either a Uint8Array, a number, a boolean, null,
|
||||||
// They never throw — callers may inspect the boolean result or rely on
|
// or fail closed. They never throw — callers may inspect the result
|
||||||
// the canon-byte length to detect malformed input.
|
// or rely on the canon-byte length to detect malformed input.
|
||||||
|
|
||||||
//go:build js && wasm
|
//go:build js && wasm
|
||||||
|
|
||||||
@@ -30,6 +42,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
|
|
||||||
|
"galaxy/core/calc"
|
||||||
"galaxy/core/canon"
|
"galaxy/core/canon"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +52,13 @@ func main() {
|
|||||||
"verifyResponse": js.FuncOf(verifyResponse),
|
"verifyResponse": js.FuncOf(verifyResponse),
|
||||||
"verifyEvent": js.FuncOf(verifyEvent),
|
"verifyEvent": js.FuncOf(verifyEvent),
|
||||||
"verifyPayloadHash": js.FuncOf(verifyPayloadHash),
|
"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
|
// 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)
|
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
|
// copyBytesFromJS materialises a JS Uint8Array (or any indexable
|
||||||
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
|
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
|
||||||
// because TinyGo's implementation panics on values it does not
|
// because TinyGo's implementation panics on values it does not
|
||||||
|
|||||||
Reference in New Issue
Block a user