From e4dc0ce0291965e23c2c2b27fb2b00bed82d8cc9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 9 May 2026 23:14:40 +0200 Subject: [PATCH] ui/phase-18: ship-class calc bridge with live designer preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/PLAN.md | 63 ++++-- ui/core/README.md | 19 ++ ui/core/calc/ship.go | 57 +++++ ui/core/calc/ship_test.go | 213 ++++++++++++++++++ ui/core/go.mod | 5 +- ui/docs/calc-bridge.md | 90 ++++++-- ui/frontend/src/api/game-state.ts | 60 +++-- .../active-view/designer-ship-class.svelte | 144 +++++++++++- ui/frontend/src/lib/core-context.svelte.ts | 31 +++ ui/frontend/src/lib/i18n/locales/en.ts | 7 + ui/frontend/src/lib/i18n/locales/ru.ts | 7 + ui/frontend/src/platform/core/index.ts | 86 +++++++ ui/frontend/src/platform/core/wasm.ts | 35 +++ .../src/routes/games/[id]/+layout.svelte | 7 + ui/frontend/static/core.wasm | Bin 925127 -> 1034403 bytes ui/frontend/tests/designer-ship-class.test.ts | 145 ++++++++++++ ui/frontend/tests/galaxy-client.test.ts | 9 + ui/frontend/tests/game-shell-header.test.ts | 3 + ui/frontend/tests/game-shell-sidebar.test.ts | 3 + ui/frontend/tests/inspector-overlay.test.ts | 3 + ui/frontend/tests/map-cargo-routes.test.ts | 6 + ui/frontend/tests/order-overlay.test.ts | 3 + ui/frontend/tests/state-binding.test.ts | 3 + ui/frontend/tests/table-ship-classes.test.ts | 3 + ui/wasm/main.go | 118 +++++++++- 25 files changed, 1056 insertions(+), 64 deletions(-) create mode 100644 ui/core/calc/ship.go create mode 100644 ui/core/calc/ship_test.go create mode 100644 ui/frontend/src/lib/core-context.svelte.ts diff --git a/ui/PLAN.md b/ui/PLAN.md index 5cda297..5b67e89 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -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) diff --git a/ui/core/README.md b/ui/core/README.md index 67affe2..c8708cd 100644 --- a/ui/core/README.md +++ b/ui/core/README.md @@ -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 diff --git a/ui/core/calc/ship.go b/ui/core/calc/ship.go new file mode 100644 index 0000000..d1bf5e8 --- /dev/null +++ b/ui/core/calc/ship.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) +} diff --git a/ui/core/calc/ship_test.go b/ui/core/calc/ship_test.go new file mode 100644 index 0000000..de6f3c1 --- /dev/null +++ b/ui/core/calc/ship_test.go @@ -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) +} diff --git a/ui/core/go.mod b/ui/core/go.mod index 82efcaf..0470cbf 100644 --- a/ui/core/go.mod +++ b/ui/core/go.mod @@ -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 diff --git a/ui/docs/calc-bridge.md b/ui/docs/calc-bridge.md index bf66321..15446a2 100644 --- a/ui/docs/calc-bridge.md +++ b/ui/docs/calc-bridge.md @@ -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/_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. diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index 0445a8b..7a1700f 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -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 }; } /** diff --git a/ui/frontend/src/lib/active-view/designer-ship-class.svelte b/ui/frontend/src/lib/active-view/designer-ship-class.svelte index 87787c7..5414bb8 100644 --- a/ui/frontend/src/lib/active-view/designer-ship-class.svelte +++ b/ui/frontend/src/lib/active-view/designer-ship-class.svelte @@ -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 `