ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
25 changed files with 1056 additions and 64 deletions
Showing only changes of commit e4dc0ce029 - Show all commits
+42 -21
View File
@@ -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)
+19
View File
@@ -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
+57
View File
@@ -0,0 +1,57 @@
// Package calc is the WASM-side bridge over `galaxy/calc`'s ship math.
// Each function is a one-line passthrough: signatures match the
// underlying `pkg/calc/ship.go` exactly so the bridge contains zero
// math beyond the call. Wrapping `pkg/calc` here keeps the JS/Go
// surface in one file and lets the canonical math live in a single
// upstream package shared with the engine.
package calc
import "galaxy/calc"
// DriveEffective wraps `calc.DriveEffective` (`pkg/calc/ship.go`):
// effective drive power equals the ship's drive block multiplied by
// the player's drive tech level.
func DriveEffective(drive, driveTech float64) float64 {
return calc.DriveEffective(drive, driveTech)
}
// EmptyMass wraps `calc.EmptyMass` (`pkg/calc/ship.go`): mass of the
// ship without cargo. Returns ok == false when the weapons/armament
// pair is invalid (one zero, the other non-zero).
func EmptyMass(drive, weapons float64, armament uint, shields, cargo float64) (float64, bool) {
return calc.EmptyMass(drive, weapons, armament, shields, cargo)
}
// WeaponsBlockMass wraps `calc.WeaponsBlockMass` (`pkg/calc/ship.go`):
// mass of the weapons sub-block. Returns ok == false on the same
// invalid pairing as EmptyMass.
func WeaponsBlockMass(weapons float64, armament uint) (float64, bool) {
return calc.WeaponsBlockMass(weapons, armament)
}
// FullMass wraps `calc.FullMass` (`pkg/calc/ship.go`): empty mass plus
// the mass of the carried cargo.
func FullMass(emptyMass, carryingMass float64) float64 {
return calc.FullMass(emptyMass, carryingMass)
}
// Speed wraps `calc.Speed` (`pkg/calc/ship.go`): light-years per turn,
// equal to effective drive times 20 divided by the ship's full mass.
// Zero when fullMass is non-positive.
func Speed(driveEffective, fullMass float64) float64 {
return calc.Speed(driveEffective, fullMass)
}
// CargoCapacity wraps `calc.CargoCapacity` (`pkg/calc/ship.go`):
// hold capacity of one ship in cargo units, scaled by the player's
// cargo tech.
func CargoCapacity(cargo, cargoTech float64) float64 {
return calc.CargoCapacity(cargo, cargoTech)
}
// CarryingMass wraps `calc.CarryingMass` (`pkg/calc/ship.go`): mass of
// a payload of `load` cargo units at the player's cargo tech. Used by
// the designer preview to derive full-load mass from CargoCapacity.
func CarryingMass(load, cargoTech float64) float64 {
return calc.CarryingMass(load, cargoTech)
}
+213
View File
@@ -0,0 +1,213 @@
package calc_test
import (
"testing"
source "galaxy/calc"
bridge "galaxy/core/calc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// shipFixture is the input set passed to every parity check. Values
// are picked to exercise both the typical mid-tech ship and the
// invalid weapons/armament pairing path of EmptyMass /
// WeaponsBlockMass.
type shipFixture struct {
name string
drive float64
armament uint
weapons float64
shields float64
cargo float64
driveTech float64
cargoTech float64
}
func fixtures() []shipFixture {
return []shipFixture{
{
name: "all_zero",
drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0,
driveTech: 1, cargoTech: 1,
},
{
name: "typical_mid_tech",
drive: 8, armament: 2, weapons: 5, shields: 3, cargo: 4,
driveTech: 1.5, cargoTech: 1.2,
},
{
name: "heavy_armoured",
drive: 3, armament: 5, weapons: 12, shields: 20, cargo: 1,
driveTech: 0.8, cargoTech: 0.5,
},
{
name: "invalid_weapons_no_armament",
drive: 5, armament: 0, weapons: 4, shields: 1, cargo: 2,
driveTech: 1, cargoTech: 1,
},
{
name: "invalid_armament_no_weapons",
drive: 5, armament: 3, weapons: 0, shields: 1, cargo: 2,
driveTech: 1, cargoTech: 1,
},
}
}
func TestDriveEffectiveParity(t *testing.T) {
t.Parallel()
for _, f := range fixtures() {
t.Run(f.name, func(t *testing.T) {
t.Parallel()
want := source.DriveEffective(f.drive, f.driveTech)
got := bridge.DriveEffective(f.drive, f.driveTech)
assert.Equal(t, want, got)
})
}
}
func TestEmptyMassParity(t *testing.T) {
t.Parallel()
for _, f := range fixtures() {
t.Run(f.name, func(t *testing.T) {
t.Parallel()
wantMass, wantOk := source.EmptyMass(f.drive, f.weapons, f.armament, f.shields, f.cargo)
gotMass, gotOk := bridge.EmptyMass(f.drive, f.weapons, f.armament, f.shields, f.cargo)
assert.Equal(t, wantOk, gotOk)
assert.Equal(t, wantMass, gotMass)
})
}
}
func TestWeaponsBlockMassParity(t *testing.T) {
t.Parallel()
for _, f := range fixtures() {
t.Run(f.name, func(t *testing.T) {
t.Parallel()
wantMass, wantOk := source.WeaponsBlockMass(f.weapons, f.armament)
gotMass, gotOk := bridge.WeaponsBlockMass(f.weapons, f.armament)
assert.Equal(t, wantOk, gotOk)
assert.Equal(t, wantMass, gotMass)
})
}
}
func TestFullMassParity(t *testing.T) {
t.Parallel()
cases := []struct {
name string
emptyMass float64
carrying float64
}{
{"zero", 0, 0},
{"empty_only", 25, 0},
{"loaded", 25, 12.5},
{"negative_carrying_clamped_by_caller", 25, -3},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
want := source.FullMass(c.emptyMass, c.carrying)
got := bridge.FullMass(c.emptyMass, c.carrying)
assert.Equal(t, want, got)
})
}
}
func TestSpeedParity(t *testing.T) {
t.Parallel()
cases := []struct {
name string
driveEffective float64
fullMass float64
}{
{"zero_mass_returns_zero", 12, 0},
{"negative_mass_returns_zero", 12, -1},
{"typical", 12, 30},
{"fast_light_ship", 50, 5},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
want := source.Speed(c.driveEffective, c.fullMass)
got := bridge.Speed(c.driveEffective, c.fullMass)
assert.Equal(t, want, got)
})
}
}
func TestCargoCapacityParity(t *testing.T) {
t.Parallel()
for _, f := range fixtures() {
t.Run(f.name, func(t *testing.T) {
t.Parallel()
want := source.CargoCapacity(f.cargo, f.cargoTech)
got := bridge.CargoCapacity(f.cargo, f.cargoTech)
assert.Equal(t, want, got)
})
}
}
func TestCarryingMassParity(t *testing.T) {
t.Parallel()
cases := []struct {
name string
load float64
cargoTech float64
}{
{"zero_load", 0, 1},
{"negative_load_returns_zero", -5, 1},
{"typical_high_tech", 24, 2},
{"low_tech_amplifies", 24, 0.5},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
want := source.CarryingMass(c.load, c.cargoTech)
got := bridge.CarryingMass(c.load, c.cargoTech)
assert.Equal(t, want, got)
})
}
}
// TestDesignerPreviewComposition exercises the exact composition the
// ship-class designer performs: empty mass, full-load mass via
// CarryingMass(CargoCapacity), max speed at empty, and range at full
// load. Catches regressions if a future bridge tweak silently changes
// the composition shape.
func TestDesignerPreviewComposition(t *testing.T) {
t.Parallel()
const (
drive = 8.0
armament = uint(2)
weapons = 5.0
shields = 3.0
cargo = 4.0
driveTech = 1.5
cargoTech = 1.2
)
emptyMass, ok := bridge.EmptyMass(drive, weapons, armament, shields, cargo)
require.True(t, ok)
cargoCap := bridge.CargoCapacity(cargo, cargoTech)
carryAtFull := bridge.CarryingMass(cargoCap, cargoTech)
fullLoadMass := bridge.FullMass(emptyMass, carryAtFull)
driveEff := bridge.DriveEffective(drive, driveTech)
maxSpeed := bridge.Speed(driveEff, emptyMass)
rangePerTurn := bridge.Speed(driveEff, fullLoadMass)
wantEmpty, _ := source.EmptyMass(drive, weapons, armament, shields, cargo)
wantCap := source.CargoCapacity(cargo, cargoTech)
wantCarry := source.CarryingMass(wantCap, cargoTech)
wantFull := source.FullMass(wantEmpty, wantCarry)
wantDE := source.DriveEffective(drive, driveTech)
wantMaxSpeed := source.Speed(wantDE, wantEmpty)
wantRange := source.Speed(wantDE, wantFull)
assert.Equal(t, wantEmpty, emptyMass)
assert.Equal(t, wantCap, cargoCap)
assert.Equal(t, wantCarry, carryAtFull)
assert.Equal(t, wantFull, fullLoadMass)
assert.Equal(t, wantMaxSpeed, maxSpeed)
assert.Equal(t, wantRange, rangePerTurn)
}
+4 -1
View File
@@ -2,7 +2,10 @@ module galaxy/core
go 1.26.0 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
View File
@@ -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.
+43 -17
View File
@@ -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;
}
}
+7
View File
@@ -238,6 +238,13 @@ const en = {
"game.designer.ship_class.invalid.cargo_value": "cargo must be 0 or ≥ 1", "game.designer.ship_class.invalid.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;
+7
View File
@@ -239,6 +239,13 @@ const ru: Record<keyof typeof en, string> = {
"game.designer.ship_class.invalid.cargo_value": "трюм должен быть 0 или ≥ 1", "game.designer.ship_class.invalid.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;
+86
View File
@@ -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>;
+35
View File
@@ -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"),
);
});
});
+9
View File
@@ -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([]);
}); });
+3
View File
@@ -50,6 +50,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
localShipClass: [], localShipClass: [],
routes: [], routes: [],
localPlayerDrive: 0, localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
}; };
} }
+3
View File
@@ -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
View File
@@ -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