diff --git a/game/internal/controller/planet.go b/game/internal/controller/planet.go index 8be2294..19af652 100644 --- a/game/internal/controller/planet.go +++ b/game/internal/controller/planet.go @@ -272,29 +272,19 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint { if productionAvailable <= 0 { return 0 } - ships := uint(0) - pa := productionAvailable - var MATneed, totalCost float64 - for { - MATneed = shipMass - float64(p.Material) - if MATneed < 0 { - MATneed = 0 - } - totalCost = calc.ShipBuildCost(shipMass, float64(p.Material), float64(p.Resources)) - if pa < totalCost { - progress := pa / totalCost - pval := game.F(progress) - if p.Production.Progress != nil { - pval += *p.Production.Progress - } - p.Production.Progress = &pval - fval := game.F(pa) - p.Production.ProdUsed = &fval - return ships - } else { - pa -= totalCost - p.Mat(float64(p.Material) - shipMass + MATneed) - ships += 1 - } + ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn( + productionAvailable, + float64(p.Material), + float64(p.Resources), + shipMass, + ) + p.Mat(materialLeft) + pval := game.F(progress) + if p.Production.Progress != nil { + pval += *p.Production.Progress } + p.Production.Progress = &pval + used := game.F(productionUsed) + p.Production.ProdUsed = &used + return ships } diff --git a/game/internal/model/game/group.go b/game/internal/model/game/group.go index 845c6d7..9c202c8 100644 --- a/game/internal/model/game/group.go +++ b/game/internal/model/game/group.go @@ -3,7 +3,6 @@ package game import ( "fmt" "galaxy/calc" - "math" "strings" "github.com/google/uuid" @@ -208,11 +207,12 @@ func (sg ShipGroup) Speed(st *ShipType) float64 { // Мощность бомбардировки func (sg ShipGroup) BombingPower(st *ShipType) float64 { - return (math.Sqrt(st.Weapons.F()*sg.TechLevel(TechWeapons).F())/10. + 1.) * - st.Weapons.F() * - sg.TechLevel(TechWeapons).F() * - float64(st.Armament) * - float64(sg.Number) + return calc.BombingPower( + st.Weapons.F(), + sg.TechLevel(TechWeapons).F(), + float64(st.Armament), + float64(sg.Number), + ) } func (sg ShipGroup) CargoString() string { diff --git a/pkg/calc/planet.go b/pkg/calc/planet.go index 237eca5..d7ff350 100644 --- a/pkg/calc/planet.go +++ b/pkg/calc/planet.go @@ -22,10 +22,11 @@ func PlanetProduceShipMass(L, Mat, Res float64) float64 { // resources is expected to be positive in normal play; the helper // guards against a non-positive value by collapsing the material- // farming term to zero, which keeps callers numerically stable on -// pathological synthetic data. Mirrors the per-iteration math inside -// the engine's controller.ProduceShip so both surfaces — and the -// legacy-report-to-json dev tool that needs to derive prod_used from -// percent — share the same formula. +// pathological synthetic data. [ProduceShipsInTurn] composes this cost +// into the per-turn build loop that the engine's controller.ProduceShip +// delegates to, so the engine, the calculator, and the +// legacy-report-to-json dev tool (which derives prod_used from percent) +// all share one formula. func ShipBuildCost(shipMass, material, resources float64) float64 { matNeed := shipMass - material if matNeed < 0 { @@ -37,3 +38,39 @@ func ShipBuildCost(shipMass, material, resources float64) float64 { } return ShipProductionCost(shipMass) + matFarm } + +// ProduceShipsInTurn simulates one turn of ship production on a planet +// that has productionAvailable production units to spend, a material +// stockpile, a resources rating, building ships of empty mass shipMass. +// It returns the number of whole ships completed this turn, the material +// left afterwards, the production units spent on the next (still +// incomplete) ship, and that ship's progress fraction in [0, 1). +// +// Each ship consumes shipMass units of material; any shortfall is farmed +// through [ShipBuildCost] at the planet's resources rating, draining the +// stockpile to zero before farming. The loop mirrors the engine's +// per-turn build step so the calculator and the turn generator agree on +// how many ships a planet yields. productionAvailable or shipMass that is +// non-positive yields no ships and leaves the stockpile untouched. +func ProduceShipsInTurn( + productionAvailable, material, resources, shipMass float64, +) (ships uint, materialLeft, productionUsed, progress float64) { + if productionAvailable <= 0 || shipMass <= 0 { + return 0, material, 0, 0 + } + pa := productionAvailable + mat := material + for { + matNeed := shipMass - mat + if matNeed < 0 { + matNeed = 0 + } + totalCost := ShipBuildCost(shipMass, mat, resources) + if pa < totalCost { + return ships, mat, pa, pa / totalCost + } + pa -= totalCost + mat = mat - shipMass + matNeed + ships++ + } +} diff --git a/pkg/calc/planet_test.go b/pkg/calc/planet_test.go index ef2471e..0d0b0a4 100644 --- a/pkg/calc/planet_test.go +++ b/pkg/calc/planet_test.go @@ -61,3 +61,52 @@ func TestShipBuildCost(t *testing.T) { }) } } + +func TestProduceShipsInTurn(t *testing.T) { + cases := []struct { + name string + productionAvailable, material, resources, shipMass float64 + wantShips uint + wantMaterialLeft, wantProductionUsed, wantProgress float64 + }{ + { + name: "ample material: ten ships, no farming", + productionAvailable: 100, material: 100, resources: 10, shipMass: 1, + wantShips: 10, wantMaterialLeft: 90, wantProductionUsed: 0, wantProgress: 0, + }, + { + name: "no material: partial progress on a farmed ship", + productionAvailable: 114, material: 0, resources: 0.5, shipMass: 10, + // ShipBuildCost(10,0,0.5) = 100 + 10/0.5 = 120; 114/120 = 0.95. + wantShips: 0, wantMaterialLeft: 0, wantProductionUsed: 114, wantProgress: 0.95, + }, + { + name: "no production available leaves the stockpile", + productionAvailable: 0, material: 50, resources: 10, shipMass: 5, + wantShips: 0, wantMaterialLeft: 50, wantProductionUsed: 0, wantProgress: 0, + }, + { + name: "zero ship mass is guarded against an endless loop", + productionAvailable: 100, material: 50, resources: 10, shipMass: 0, + wantShips: 0, wantMaterialLeft: 50, wantProductionUsed: 0, wantProgress: 0, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn( + tc.productionAvailable, tc.material, tc.resources, tc.shipMass) + if ships != tc.wantShips { + t.Errorf("ships = %d, want %d", ships, tc.wantShips) + } + if math.Abs(materialLeft-tc.wantMaterialLeft) > 1e-9 { + t.Errorf("materialLeft = %v, want %v", materialLeft, tc.wantMaterialLeft) + } + if math.Abs(productionUsed-tc.wantProductionUsed) > 1e-9 { + t.Errorf("productionUsed = %v, want %v", productionUsed, tc.wantProductionUsed) + } + if math.Abs(progress-tc.wantProgress) > 1e-9 { + t.Errorf("progress = %v, want %v", progress, tc.wantProgress) + } + }) + } +} diff --git a/pkg/calc/ship.go b/pkg/calc/ship.go index b0919f3..7a29a8c 100644 --- a/pkg/calc/ship.go +++ b/pkg/calc/ship.go @@ -94,3 +94,14 @@ func EffectiveDefence( } return defendingShields * defendingShiledsTech / math.Pow(defendingFullMass, 1./3.) * math.Pow(30., 1./3.) } + +// BombingPower returns the bombing power of number ships whose weapons +// block is weapons, built at weapons tech level weaponsTech and carrying +// armament weapon mounts. The leading factor sqrt(weapons*weaponsTech)/10 +// + 1 makes the power grow super-linearly with effective weapon strength, +// which then scales linearly with weapons, weaponsTech, armament, and +// number. With zero armament or zero weapons the power is zero. +func BombingPower(weapons, weaponsTech, armament, number float64) float64 { + return (math.Sqrt(weapons*weaponsTech)/10. + 1.) * + weapons * weaponsTech * armament * number +} diff --git a/pkg/calc/ship_test.go b/pkg/calc/ship_test.go index 09b0ba1..7abd5c7 100644 --- a/pkg/calc/ship_test.go +++ b/pkg/calc/ship_test.go @@ -32,3 +32,27 @@ func TestBlockUpgradeCost(t *testing.T) { }) } } + +func TestBombingPower(t *testing.T) { + cases := []struct { + name string + weapons, weaponsTech, armament, number float64 + want float64 + }{ + // Parity with the engine's Battle_Station fixture + // (game/internal/model/game/group_test.go): (sqrt(30)/10+1)*30*3. + {"battle station, single ship", 30, 1, 3, 1, 139.29503}, + {"battle station, two ships scale linearly", 30, 1, 3, 2, 278.59006}, + {"no armament: zero power", 30, 1, 0, 5, 0}, + {"no weapons: zero power", 0, 1, 3, 5, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := calc.BombingPower(tc.weapons, tc.weaponsTech, tc.armament, tc.number) + if math.Abs(got-tc.want) > 1e-3 { + t.Errorf("BombingPower(%v, %v, %v, %v) = %v, want %v", + tc.weapons, tc.weaponsTech, tc.armament, tc.number, got, tc.want) + } + }) + } +} diff --git a/pkg/calc/solve.go b/pkg/calc/solve.go new file mode 100644 index 0000000..44b5f57 --- /dev/null +++ b/pkg/calc/solve.go @@ -0,0 +1,86 @@ +package calc + +// This file holds the inverse ("goal-seek") counterparts of the forward +// ship formulas. The ship-class calculator lets a player pin one derived +// result and back-solve the single input it claims; each solver inverts +// exactly one forward function so the math stays in this package rather +// than leaking into the UI bridge. Every solver reports ok == false when +// the request is infeasible (e.g. an unreachable target or a division by +// a non-positive tech level), leaving the returned value undefined. + +// WeaponsForAttack returns the weapons block that yields targetAttack at +// weapons tech level weaponsTech, inverting [EffectiveAttack]. It is +// infeasible when weaponsTech is non-positive or targetAttack is +// negative. +func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) { + if weaponsTech <= 0 || targetAttack < 0 { + return 0, false + } + return targetAttack / weaponsTech, true +} + +// DriveForSpeed returns the drive block that yields targetSpeed for a +// ship whose mass excluding the drive block is restMass, at drive tech +// level driveTech, inverting [Speed] composed with [DriveEffective]. +// Speed approaches but never reaches the stripped-hull ceiling +// 20*driveTech, so a target at or above the ceiling (or a non-positive +// target or tech level) is infeasible. +func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) { + ceiling := 20 * driveTech + if driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling { + return 0, false + } + return targetSpeed * restMass / (ceiling - targetSpeed), true +} + +// ShieldsForDefence returns the shields block that yields targetDefence +// for a ship whose mass excluding the shields block is restMass, at +// shields tech level shieldsTech, inverting [EffectiveDefence]. Defence +// rises monotonically with shields (the block adds mass to its own +// denominator), so the block is found by bisection. It is infeasible when +// targetDefence or shieldsTech is non-positive. +func ShieldsForDefence(targetDefence, shieldsTech, restMass float64) (float64, bool) { + if targetDefence <= 0 || shieldsTech <= 0 { + return 0, false + } + lo, hi := 0.0, 1.0 + for EffectiveDefence(hi, shieldsTech, hi+restMass) < targetDefence { + hi *= 2 + if hi > 1e12 { + return 0, false + } + } + for range 100 { + mid := (lo + hi) / 2 + if EffectiveDefence(mid, shieldsTech, mid+restMass) < targetDefence { + lo = mid + } else { + hi = mid + } + } + return (lo + hi) / 2, true +} + +// CargoForEmptyMass returns the cargo block that brings a ship's empty +// mass to targetEmptyMass, given restMass — the combined mass of the +// other blocks (drive, shields, and the weapons block) — inverting the +// cargo term of [EmptyMass]. It is infeasible when targetEmptyMass is +// below restMass, which would require a negative cargo block. +func CargoForEmptyMass(targetEmptyMass, restMass float64) (float64, bool) { + cargo := targetEmptyMass - restMass + if cargo < 0 { + return 0, false + } + return cargo, true +} + +// LoadForFullMass returns the cargo load that brings a ship's full mass +// to targetFullMass, given its empty mass and cargo tech level, inverting +// [CarryingMass] inside [FullMass]. It is infeasible when targetFullMass +// is below emptyMass or cargoTech is non-positive. +func LoadForFullMass(targetFullMass, emptyMass, cargoTech float64) (float64, bool) { + if cargoTech <= 0 || targetFullMass < emptyMass { + return 0, false + } + return (targetFullMass - emptyMass) * cargoTech, true +} diff --git a/pkg/calc/solve_test.go b/pkg/calc/solve_test.go new file mode 100644 index 0000000..a032fce --- /dev/null +++ b/pkg/calc/solve_test.go @@ -0,0 +1,66 @@ +package calc_test + +import ( + "math" + "testing" + + "galaxy/calc" +) + +func TestWeaponsForAttack(t *testing.T) { + got, ok := calc.WeaponsForAttack(calc.EffectiveAttack(12, 1.5), 1.5) + if !ok || math.Abs(got-12) > 1e-9 { + t.Errorf("WeaponsForAttack round-trip = %v (ok=%v), want 12", got, ok) + } + if _, ok := calc.WeaponsForAttack(10, 0); ok { + t.Error("WeaponsForAttack with zero tech should be infeasible") + } +} + +func TestDriveForSpeed(t *testing.T) { + const drive, driveTech, restMass = 10.0, 1.2, 35.0 + speed := calc.Speed(calc.DriveEffective(drive, driveTech), drive+restMass) + got, ok := calc.DriveForSpeed(speed, driveTech, restMass) + if !ok || math.Abs(got-drive) > 1e-9 { + t.Errorf("DriveForSpeed round-trip = %v (ok=%v), want %v", got, ok, drive) + } + // Speed can never reach the stripped-hull ceiling 20*driveTech. + if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok { + t.Error("DriveForSpeed at the speed ceiling should be infeasible") + } +} + +func TestShieldsForDefence(t *testing.T) { + const shields, shieldsTech, restMass = 5.75, 1.0, 40.0 + defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass) + got, ok := calc.ShieldsForDefence(defence, shieldsTech, restMass) + if !ok || math.Abs(got-shields) > 1e-6 { + t.Errorf("ShieldsForDefence round-trip = %v (ok=%v), want %v", got, ok, shields) + } + if _, ok := calc.ShieldsForDefence(0, shieldsTech, restMass); ok { + t.Error("ShieldsForDefence at a zero target should be infeasible") + } +} + +func TestCargoForEmptyMass(t *testing.T) { + const restMass, cargo = 30.0, 12.0 + got, ok := calc.CargoForEmptyMass(restMass+cargo, restMass) + if !ok || math.Abs(got-cargo) > 1e-9 { + t.Errorf("CargoForEmptyMass round-trip = %v (ok=%v), want %v", got, ok, cargo) + } + if _, ok := calc.CargoForEmptyMass(restMass-1, restMass); ok { + t.Error("CargoForEmptyMass below the fixed block mass should be infeasible") + } +} + +func TestLoadForFullMass(t *testing.T) { + const emptyMass, cargoTech, load = 45.0, 1.0, 20.0 + full := calc.FullMass(emptyMass, calc.CarryingMass(load, cargoTech)) + got, ok := calc.LoadForFullMass(full, emptyMass, cargoTech) + if !ok || math.Abs(got-load) > 1e-9 { + t.Errorf("LoadForFullMass round-trip = %v (ok=%v), want %v", got, ok, load) + } + if _, ok := calc.LoadForFullMass(emptyMass-1, emptyMass, cargoTech); ok { + t.Error("LoadForFullMass below empty mass should be infeasible") + } +} diff --git a/ui/PLAN.md b/ui/PLAN.md index c988b25..c4f3c97 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -1968,7 +1968,11 @@ Decisions baked into Phase 16 (vs. the original stage description): ## ~~Phase 17. Ship Classes — CRUD Without Calc~~ -Status: done (local-ci run 20). +Status: done (local-ci run 20). Note: Phase 30 removed the standalone +designer view/route described below and folded ship-class create/view +into the sidebar calculator; the table's row-activate and "new" button +now open the calculator. The table itself and the validator are +unchanged. Goal: list, view, create, and delete ship classes through a dedicated table view and a designer view; numeric calculations are @@ -2037,7 +2041,10 @@ Targeted tests: ## ~~Phase 18. Ship Classes — Calc Bridge~~ -Status: done. +Status: done. Note: the live mass/speed/range preview built here moved +into the Phase 30 calculator when the standalone designer was removed; +the `ui/core/calc` ship bridge wrapped here is reused unchanged and +extended by Phase 30 (combat, planet build, solvers). Goal: wire `pkg/calc/` ship math into the designer for live mass, speed, range, and cargo capacity previews. @@ -3314,39 +3321,92 @@ Decisions: rendered result is verified by a high-contrast screenshot during development plus the existing fog / render-on-demand e2e. -## Phase 30. Calculator Tab +## Phase 30. Ship Class Calculator Status: pending. -Goal: ship an independent calculator in the sidebar, callable from any -view, exposing the full set of `pkg/calc/` functions wired through -`Core`. +Goal: replace the standalone Phase 17/18 ship-class designer with a +fused designer + calculator living in the sidebar. It shows the ship +design blocks, live derived results (mass, speed, attack, defence, +bombing), and a planet build-rate readout, and adds single-target +goal-seek — the player pins one result and the model back-solves the +single input it claims. A second mode reuses the design area to price +ship-class modernization. The standalone designer view and route are +removed; the ship-classes table and the view/bottom menus open the +calculator instead. + +The original four detached modes (ship / path / modernization / +bombing) were dropped during planning: path is deferred (MVP path is +brute force) and replaced by auto reach circles on the map; bombing is +folded in as a per-ship result; ship and modernization are the two +modes. See `ui/CALCULATOR.md` history (removed) and the interview +decisions baked below. + +Goal-seek claim map (one lock at a time): attack → weapons, +defence → shields, empty/loaded speed → drive, empty mass → cargo, +loaded mass → cargo load. Locking one result disables the others' +lock affordances; an unreachable target shows the locked cell in an +error state. Tech levels and the planet MAT are override inputs with a +reset lock; the player tech is the default. Artifacts: -- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` UI with mode - selector (ship calculator, path calculator, modernization cost, - bombing power) and per-mode forms -- bridge entries in `ui/core/calc/` for any function not already - wrapped by Phase 18 -- topic doc `ui/docs/calculator-ux.md` documenting modes, - layouts, and the rule that calculator inputs persist across - navigation +- `pkg/calc/` additions, single-sourced (no mirroring): `BombingPower` + extracted from `game/internal/model/game/group.go`; a pure + `ProduceShipsInTurn` extracted from `controller.ProduceShip` (the + engine now delegates to both); inverse solvers in `pkg/calc/solve.go` + (`WeaponsForAttack`, `DriveForSpeed`, `ShieldsForDefence` by + bisection, `CargoForEmptyMass`, `LoadForFullMass`) +- thin bridges in `ui/core/calc/` (combat, planet build, solvers), + registered in `ui/wasm/main.go`, typed on `Core` + (`platform/core/index.ts` + `wasm.ts`) +- `ui/frontend/src/lib/calculator/calc-model.ts` pure orchestration + (forward results + single-target goal-seek + planet build) +- `ui/frontend/src/lib/calculator/ship-design-area.svelte` reusable + design block (5 blocks + 4 techs, override locks, computed-block + read-only) — earmarked for the future ship-group upgrade flow +- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` shell (mode + selector, name combobox + Create / Delete, the calc and planet areas + inline) +- `ui/frontend/src/map/reach-circles.ts` + `lib/calculator/reach.svelte.ts` + shared store: the calculator publishes the selected planet origin and + loaded speed, the map draws 1–3 reach circles +- `lib/calculator/load-request.svelte.ts` shared store: the table / + menus ask the layout to open the calculator on a class +- topic doc `ui/docs/calculator-ux.md`; `ui/docs/calc-bridge.md` + extended with the new wired functions -Dependencies: Phase 18. +Dependencies: Phases 17, 18, 19/20 (selection store), 29 (map modes). Acceptance criteria: -- every calculator mode produces results identical to direct - `pkg/calc/` calls; -- inputs persist across view switches per global state-preservation - rule; -- calculator works in history mode against the snapshot's tech levels. +- every result is byte-identical to direct `pkg/calc/` calls on shared + fixtures (Go parity tests); +- locking one result back-solves its claimed input; a second lock is + disabled; an unreachable target shows the error state; +- Create reuses the existing ship-class command flow and validator; + selecting an existing class loads it as a template; +- inputs persist across view switches per the global state-preservation + rule; the calculator works in history mode against the snapshot's + tech levels; +- selecting an own planet draws the reach circles; clearing the + selection or an invalid design removes them; +- the standalone designer view/route no longer exists. Targeted tests: -- Vitest snapshot tests per mode on canonical inputs; -- Playwright e2e: switch modes, confirm input persistence. +- Go: `pkg/calc` unit tests + engine parity (`ProduceShip`, + `BombingPower`); `ui/core/calc` bridge parity; solver round-trips; +- Vitest: `calc-model` (forward, goal-seek per claim, infeasible), + `calculator-tab` (results, goal-seek, Create, planet area), + `reach-circles` math; +- Playwright e2e: create / list / delete via the table + calculator, + Create-disabled-while-invalid (`tests/e2e/ship-classes.spec.ts`). + +Note: the WASM artefact `ui/frontend/static/core.wasm` must be rebuilt +(`make wasm`, needs TinyGo) for the new bridge functions to be present +at runtime and in the Playwright suite; Vitest injects a fake `Core` +and does not need the rebuild. ## Phase 31. Wails Desktop Wrapper @@ -3483,54 +3543,56 @@ Targeted tests: - regression test: bumping the app version invalidates the prior service worker. -## Phase 34. Multi-Turn Projection — Single-Turn Forecast and Range Circles +## Phase 34. Multi-Turn Projection — Realistic Planet Forecast Status: pending. Long-term scope deferred but this phase ships real features. -Goal: ship two concrete projection features (planet next-turn -forecast and ship-designer reach circles) plus the transient -map-overlay back-stack mechanism that the reach-circles feature is -the first user of. +Goal: ship a realistic multi-turn planet projection and surface it in +the planet inspector and in the calculator's planet area. Reach circles +already shipped in Phase 30 (auto-drawn from the calculator's selected +planet); this phase no longer owns them. + +The Phase 30 planet area is single-turn (MAT-only): it answers "ships +this turn / turns per ship" at the current or overridden MAT. This phase +makes it realistic and multi-turn by extracting the planet economy into +`pkg/calc` and simulating turns: population growth (`×1.08`), material / +capital / colonist supply, and the capital/colonist unpacking that +mirrors `MakeTurn` steps 09/12/14/15. CAP and COL only affect future +turns (post-production unpacking), so they become meaningful here and +are added to the calculator's planet area as supply inputs alongside +MAT. Artifacts: -- `ui/frontend/src/lib/projection/` minimal projection engine that - computes one-turn-ahead state for a single planet using `pkg/calc/` -- planet inspector forecast section showing next-turn population, - industry, materials stockpile, and production progress -- `ui/frontend/src/lib/navigation/transient-overlay.ts` push/pop - back-stack mechanism for map overlays driven by other views, with - a back-button affordance on the map that returns to the originating - view with state preserved -- ship-designer `Preview range on map` action that pushes a transient - overlay onto the map showing concentric reach circles for 1, 2, 3, - 4 turns from a chosen origin, computed from the in-progress ship - design and the player's current Drive tech via `ui/core/calc/` -- topic doc `ui/docs/multi-turn-projection.md` describing the - long-term vision (multi-turn planning mode, scenario branches) and - the phased path to it +- `pkg/calc/` planet-economy extraction (single-sourced, engine + delegates): `PlanetProduction`, `ProducePopulation`, + `UnpackColonists`, `UnpackCapital`, reusing `ProduceShipsInTurn`; a + multi-turn projector `ProjectPlanetBuild` answering "K ships in M + turns" under guaranteed per-turn supply +- thin bridges in `ui/core/calc/` + `Core` typings +- planet inspector forecast section (next-turn population, industry, + materials, production progress) +- calculator planet area gains CAP and COL supply inputs and switches + its readout to the multi-turn projector +- topic doc `ui/docs/multi-turn-projection.md` (long-term vision: + multi-turn planning mode, scenario branches) -Dependencies: Phases 17, 18. +Dependencies: Phases 17, 18, 30. Acceptance criteria: -- the planet inspector shows a forecast section with next-turn values - matching `pkg/calc/` outputs; -- the ship-designer `Preview range on map` button transitions to the - map with reach circles drawn from the chosen origin; back returns - to the designer with all in-progress state intact; -- the transient overlay is cleared if the user navigates to any other - view via the header dropdown. +- projector output is byte-identical to running the engine's per-turn + planet update over the same turns (Go parity); +- the planet inspector shows a forecast section matching it; +- the calculator planet area honours MAT / CAP / COL supply and shows + "K ships in M turns" consistent with the projector. Targeted tests: -- Vitest unit tests for the projection engine on canonical fixtures; -- Vitest unit tests for the transient-overlay push/pop logic and - state preservation; -- Playwright e2e: open a planet inspector, observe one-turn forecast; - open a ship designer, click `Preview range on map`, see reach - circles, click back, return with state intact. +- Go parity tests for each extracted economy formula and the projector; +- Vitest for the calculator planet area with supply inputs; +- Playwright e2e: planet inspector forecast section. ## Phase 35. Polish — Accessibility, Localisation, Error UX diff --git a/ui/core/calc/planet.go b/ui/core/calc/planet.go new file mode 100644 index 0000000..c236381 --- /dev/null +++ b/ui/core/calc/planet.go @@ -0,0 +1,22 @@ +package calc + +import "galaxy/calc" + +// ShipBuildCost wraps `calc.ShipBuildCost` (`pkg/calc/planet.go`): the +// per-turn production cost of one ship of empty mass shipMass on a planet +// holding the given material stockpile at the given resources rating. +func ShipBuildCost(shipMass, material, resources float64) float64 { + return calc.ShipBuildCost(shipMass, material, resources) +} + +// ProduceShipsInTurn wraps `calc.ProduceShipsInTurn` +// (`pkg/calc/planet.go`): one turn of ship production. It returns the +// whole ships completed this turn, the material left afterwards, the +// production units spent on the next (incomplete) ship, and that ship's +// progress fraction. The calculator's planet area renders ships-per-turn +// and turns-per-ship from this single call so it agrees with the engine. +func ProduceShipsInTurn( + productionAvailable, material, resources, shipMass float64, +) (uint, float64, float64, float64) { + return calc.ProduceShipsInTurn(productionAvailable, material, resources, shipMass) +} diff --git a/ui/core/calc/planet_test.go b/ui/core/calc/planet_test.go new file mode 100644 index 0000000..d43f212 --- /dev/null +++ b/ui/core/calc/planet_test.go @@ -0,0 +1,57 @@ +package calc_test + +import ( + "testing" + + source "galaxy/calc" + bridge "galaxy/core/calc" + + "github.com/stretchr/testify/assert" +) + +func TestShipBuildCostParity(t *testing.T) { + t.Parallel() + cases := []struct { + name string + shipMass, material, resources float64 + }{ + {"material_covers_mass", 5, 10, 0.5}, + {"material_short", 10, 3, 0.5}, + {"no_material", 4, 0, 0.5}, + {"zero_resources_guard", 10, 3, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + want := source.ShipBuildCost(c.shipMass, c.material, c.resources) + got := bridge.ShipBuildCost(c.shipMass, c.material, c.resources) + assert.Equal(t, want, got) + }) + } +} + +func TestProduceShipsInTurnParity(t *testing.T) { + t.Parallel() + cases := []struct { + name string + productionAvailable, material, resources, shipMass float64 + }{ + {"ample_material", 100, 100, 10, 1}, + {"farmed_partial", 114, 0, 0.5, 10}, + {"no_production", 0, 50, 10, 5}, + {"zero_ship_mass", 100, 50, 10, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + wantShips, wantMat, wantUsed, wantProg := source.ProduceShipsInTurn( + c.productionAvailable, c.material, c.resources, c.shipMass) + gotShips, gotMat, gotUsed, gotProg := bridge.ProduceShipsInTurn( + c.productionAvailable, c.material, c.resources, c.shipMass) + assert.Equal(t, wantShips, gotShips) + assert.Equal(t, wantMat, gotMat) + assert.Equal(t, wantUsed, gotUsed) + assert.Equal(t, wantProg, gotProg) + }) + } +} diff --git a/ui/core/calc/ship.go b/ui/core/calc/ship.go index 1a446c9..33e1173 100644 --- a/ui/core/calc/ship.go +++ b/ui/core/calc/ship.go @@ -65,3 +65,26 @@ func CarryingMass(load, cargoTech float64) float64 { func BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech float64) float64 { return calc.BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech) } + +// EffectiveAttack wraps `calc.EffectiveAttack` (`pkg/calc/ship.go`): +// combat attack power of a ship, equal to its weapons block times the +// player's weapons tech. +func EffectiveAttack(weapons, weaponsTech float64) float64 { + return calc.EffectiveAttack(weapons, weaponsTech) +} + +// EffectiveDefence wraps `calc.EffectiveDefence` (`pkg/calc/ship.go`): +// combat defence power of a ship, its shields block times the player's +// shields tech, normalised by the cube root of full mass (bigger hulls +// defend worse per shield point). Zero when defendingFullMass ≤ 0. +func EffectiveDefence(defendingShields, defendingShieldsTech, defendingFullMass float64) float64 { + return calc.EffectiveDefence(defendingShields, defendingShieldsTech, defendingFullMass) +} + +// BombingPower wraps `calc.BombingPower` (`pkg/calc/ship.go`): the +// planet-bombing power of number ships with the given weapons block, +// weapons tech, and armament. The calculator passes number = 1 for a +// per-ship reading. +func BombingPower(weapons, weaponsTech, armament, number float64) float64 { + return calc.BombingPower(weapons, weaponsTech, armament, number) +} diff --git a/ui/core/calc/ship_test.go b/ui/core/calc/ship_test.go index 529802f..c9557a0 100644 --- a/ui/core/calc/ship_test.go +++ b/ui/core/calc/ship_test.go @@ -28,28 +28,28 @@ type shipFixture struct { func fixtures() []shipFixture { return []shipFixture{ { - name: "all_zero", - drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0, + 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, + 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, + 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, + 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, + name: "invalid_armament_no_weapons", + drive: 5, armament: 3, weapons: 0, shields: 1, cargo: 2, driveTech: 1, cargoTech: 1, }, } @@ -236,3 +236,66 @@ func TestDesignerPreviewComposition(t *testing.T) { assert.Equal(t, wantMaxSpeed, maxSpeed) assert.Equal(t, wantRange, rangePerTurn) } + +func TestEffectiveAttackParity(t *testing.T) { + t.Parallel() + cases := []struct { + name string + weapons float64 + weaponsTech float64 + }{ + {"zero", 0, 1}, + {"typical", 15, 1.5}, + {"high_tech", 8, 3.2}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + want := source.EffectiveAttack(c.weapons, c.weaponsTech) + got := bridge.EffectiveAttack(c.weapons, c.weaponsTech) + assert.Equal(t, want, got) + }) + } +} + +func TestEffectiveDefenceParity(t *testing.T) { + t.Parallel() + cases := []struct { + name string + shields float64 + shieldsTech float64 + fullMass float64 + }{ + {"zero_mass_returns_zero", 10, 1, 0}, + {"typical", 20, 1.2, 45}, + {"heavy_hull", 100, 1, 600}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + want := source.EffectiveDefence(c.shields, c.shieldsTech, c.fullMass) + got := bridge.EffectiveDefence(c.shields, c.shieldsTech, c.fullMass) + assert.Equal(t, want, got) + }) + } +} + +func TestBombingPowerParity(t *testing.T) { + t.Parallel() + cases := []struct { + name string + weapons, weaponsTech, armament, number float64 + }{ + {"no_armament", 30, 1, 0, 1}, + {"battle_station", 30, 1, 3, 1}, + {"fleet", 30, 1.5, 3, 4}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + want := source.BombingPower(c.weapons, c.weaponsTech, c.armament, c.number) + got := bridge.BombingPower(c.weapons, c.weaponsTech, c.armament, c.number) + assert.Equal(t, want, got) + }) + } +} diff --git a/ui/core/calc/solve.go b/ui/core/calc/solve.go new file mode 100644 index 0000000..580ce30 --- /dev/null +++ b/ui/core/calc/solve.go @@ -0,0 +1,42 @@ +package calc + +import "galaxy/calc" + +// This file bridges the inverse ("goal-seek") solvers from +// `pkg/calc/solve.go`. The ship-class calculator lets a player pin one +// derived result and back-solve the single input it claims; each wrapper +// is a one-line passthrough so the inverse math stays in `pkg/calc`. Every +// solver returns ok == false when the request is infeasible. + +// WeaponsForAttack wraps `calc.WeaponsForAttack`: the weapons block that +// yields targetAttack at weapons tech weaponsTech. +func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) { + return calc.WeaponsForAttack(targetAttack, weaponsTech) +} + +// DriveForSpeed wraps `calc.DriveForSpeed`: the drive block that yields +// targetSpeed for a ship whose mass excluding the drive block is restMass, +// at drive tech driveTech. +func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) { + return calc.DriveForSpeed(targetSpeed, driveTech, restMass) +} + +// ShieldsForDefence wraps `calc.ShieldsForDefence`: the shields block that +// yields targetDefence for a ship whose mass excluding the shields block +// is restMass, at shields tech shieldsTech. +func ShieldsForDefence(targetDefence, shieldsTech, restMass float64) (float64, bool) { + return calc.ShieldsForDefence(targetDefence, shieldsTech, restMass) +} + +// CargoForEmptyMass wraps `calc.CargoForEmptyMass`: the cargo block that +// brings empty mass to targetEmptyMass given restMass, the mass of the +// other blocks. +func CargoForEmptyMass(targetEmptyMass, restMass float64) (float64, bool) { + return calc.CargoForEmptyMass(targetEmptyMass, restMass) +} + +// LoadForFullMass wraps `calc.LoadForFullMass`: the cargo load that brings +// full mass to targetFullMass given the ship's empty mass and cargo tech. +func LoadForFullMass(targetFullMass, emptyMass, cargoTech float64) (float64, bool) { + return calc.LoadForFullMass(targetFullMass, emptyMass, cargoTech) +} diff --git a/ui/core/calc/solve_test.go b/ui/core/calc/solve_test.go new file mode 100644 index 0000000..d6d6b47 --- /dev/null +++ b/ui/core/calc/solve_test.go @@ -0,0 +1,75 @@ +package calc_test + +import ( + "testing" + + source "galaxy/calc" + bridge "galaxy/core/calc" + + "github.com/stretchr/testify/assert" +) + +func TestWeaponsForAttackParity(t *testing.T) { + t.Parallel() + cases := []struct{ targetAttack, weaponsTech float64 }{ + {18, 1.5}, {0, 1}, {10, 0}, + } + for _, c := range cases { + wantV, wantOk := source.WeaponsForAttack(c.targetAttack, c.weaponsTech) + gotV, gotOk := bridge.WeaponsForAttack(c.targetAttack, c.weaponsTech) + assert.Equal(t, wantOk, gotOk) + assert.Equal(t, wantV, gotV) + } +} + +func TestDriveForSpeedParity(t *testing.T) { + t.Parallel() + cases := []struct{ targetSpeed, driveTech, restMass float64 }{ + {5, 1.2, 35}, {24, 1.2, 35}, {0, 1, 10}, + } + for _, c := range cases { + wantV, wantOk := source.DriveForSpeed(c.targetSpeed, c.driveTech, c.restMass) + gotV, gotOk := bridge.DriveForSpeed(c.targetSpeed, c.driveTech, c.restMass) + assert.Equal(t, wantOk, gotOk) + assert.Equal(t, wantV, gotV) + } +} + +func TestShieldsForDefenceParity(t *testing.T) { + t.Parallel() + cases := []struct{ targetDefence, shieldsTech, restMass float64 }{ + {5, 1, 40}, {0, 1, 40}, {3, 0, 40}, + } + for _, c := range cases { + wantV, wantOk := source.ShieldsForDefence(c.targetDefence, c.shieldsTech, c.restMass) + gotV, gotOk := bridge.ShieldsForDefence(c.targetDefence, c.shieldsTech, c.restMass) + assert.Equal(t, wantOk, gotOk) + assert.Equal(t, wantV, gotV) + } +} + +func TestCargoForEmptyMassParity(t *testing.T) { + t.Parallel() + cases := []struct{ targetEmptyMass, restMass float64 }{ + {42, 30}, {29, 30}, + } + for _, c := range cases { + wantV, wantOk := source.CargoForEmptyMass(c.targetEmptyMass, c.restMass) + gotV, gotOk := bridge.CargoForEmptyMass(c.targetEmptyMass, c.restMass) + assert.Equal(t, wantOk, gotOk) + assert.Equal(t, wantV, gotV) + } +} + +func TestLoadForFullMassParity(t *testing.T) { + t.Parallel() + cases := []struct{ targetFullMass, emptyMass, cargoTech float64 }{ + {65, 45, 1}, {44, 45, 1}, {65, 45, 0}, + } + for _, c := range cases { + wantV, wantOk := source.LoadForFullMass(c.targetFullMass, c.emptyMass, c.cargoTech) + gotV, gotOk := bridge.LoadForFullMass(c.targetFullMass, c.emptyMass, c.cargoTech) + assert.Equal(t, wantOk, gotOk) + assert.Equal(t, wantV, gotV) + } +} diff --git a/ui/docs/calc-bridge.md b/ui/docs/calc-bridge.md index 6d88ebb..7e5e624 100644 --- a/ui/docs/calc-bridge.md +++ b/ui/docs/calc-bridge.md @@ -11,11 +11,14 @@ matching TS adapter in `ui/frontend/src/platform/core/`. Phase 18 lands the **ship-math slice** of the bridge — everything the ship-class designer needs to render its preview pane. Phase 20 extends it with `BlockUpgradeCost` so the ship-group inspector can -preview modernize cost. 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. +preview modernize cost. Phase 30 extends it with the **combat, +planet-build, and goal-seek slice** for the ship-class calculator: +`EffectiveAttack`, `EffectiveDefence`, `BombingPower`, `ShipBuildCost`, +`ProduceShipsInTurn`, and the inverse solvers from `pkg/calc/solve.go`. +Other slices (production/science forecast, the realistic multi-turn +planet projection) 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 @@ -35,7 +38,27 @@ on the JS-side `globalThis.galaxyCore` (registered in | `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) | -| `blockUpgradeCost` | `calc.BlockUpgradeCost(blockMass, currentTech, target)` | `number` | ship-group inspector modernize preview | +| `blockUpgradeCost` | `calc.BlockUpgradeCost(blockMass, currentTech, target)` | `number` | ship-group inspector + modernization mode | +| `effectiveAttack` | `calc.EffectiveAttack(weapons, weaponsTech)` | `number` | calculator (attack result) | +| `effectiveDefence` | `calc.EffectiveDefence(shields, shieldsTech, fullMass)` | `number` | calculator (defence result) | +| `bombingPower` | `calc.BombingPower(weapons, weaponsTech, armament, n)` | `number` | calculator (bombing result, n = 1) | +| `shipBuildCost` | `calc.ShipBuildCost(shipMass, material, resources)` | `number` | calculator (planet build) | +| `produceShipsInTurn`| `calc.ProduceShipsInTurn(L, material, resources, mass)` | `{ships,…}` | calculator (planet ships/turn) | +| `weaponsForAttack` | `calc.WeaponsForAttack(targetAttack, weaponsTech)` | `number\|null` | calculator goal-seek (attack → weapons) | +| `driveForSpeed` | `calc.DriveForSpeed(targetSpeed, driveTech, restMass)` | `number\|null` | calculator goal-seek (speed → drive) | +| `shieldsForDefence` | `calc.ShieldsForDefence(targetDefence, sTech, restMass)` | `number\|null` | calculator goal-seek (defence → shields) | +| `cargoForEmptyMass` | `calc.CargoForEmptyMass(targetEmptyMass, restMass)` | `number\|null` | calculator goal-seek (mass → cargo) | +| `loadForFullMass` | `calc.LoadForFullMass(targetFullMass, emptyMass, cTech)` | `number\|null` | calculator goal-seek (loaded mass → load)| + +`BombingPower` and the per-turn build loop are no longer engine-only: +Phase 30 extracted `BombingPower` from +`game/internal/model/game/group.go` and the per-iteration build math +from `controller.ProduceShip` into `pkg/calc` (`ProduceShipsInTurn`), +and the engine now delegates to both — a true refactor, not a mirror. +The inverse solvers (`pkg/calc/solve.go`) invert the forward formulas +for single-target goal-seek and return `null` when infeasible; +`shieldsForDefence` uses bisection, the rest are analytic. Parity and +round-trip tests live in `ui/core/calc/{ship,planet,solve}_test.go`. `number|null` returns mirror the Go `(float64, bool)` signature: the upstream validator rejects weapons/armament pairings with one zero @@ -85,12 +108,14 @@ whether the underlying Go function exists. | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | :-------------: | :-------------: | | Ship-class designer preview (Phase 18) | `EmptyMass`, `FullMass`, `Speed`, `DriveEffective`, `CargoCapacity`, `CarryingMass`, `WeaponsBlockMass` (`pkg/calc/ship.go`) | yes | yes | | Ship-group modernize cost preview (Phase 20) | `BlockUpgradeCost` (`pkg/calc/ship.go`, migrated from `game/internal/controller/ship_group_upgrade.go`) | yes | yes | +| Ship calculator combat (Phase 30) | `EffectiveAttack`, `EffectiveDefence`, `BombingPower` (`pkg/calc/ship.go`; `BombingPower` extracted from `model/game/group.go`) | yes | yes | +| Ship calculator goal-seek (Phase 30) | inverse solvers in `pkg/calc/solve.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 | | Per-tech research progress (DRIVE/WEAPONS/…) | `ResearchTech` (`game/internal/model/game/science.go`); `freeProduction / 5000` per tech level | no | no | | Custom-science progress | weighted form of `ResearchTech` driven by `Race.Sciences[].(Drive\|Weapons\|Shields\|Cargo)` (`science.go`) | no | no | -| Ship build progress | `PlanetProduceShipMass(L, Mat, Res) / ShipProductionCost(class.EmptyMass)` (combination of two existing exports) | partial | no | +| Ship build progress / planet build rate (Phase 30)| `ProduceShipsInTurn(L, Mat, Res, mass)` (`pkg/calc/planet.go`, extracted from `controller.ProduceShip`); `ShipBuildCost` | yes | yes | `partial` means the Go primitives exist in `pkg/calc/` but the composition (and the conversion of TS-side `ReportPlanet`/ diff --git a/ui/docs/calculator-ux.md b/ui/docs/calculator-ux.md new file mode 100644 index 0000000..376d8f1 --- /dev/null +++ b/ui/docs/calculator-ux.md @@ -0,0 +1,115 @@ +# Ship Class Calculator — UX + +Phase 30 fuses the ship-class designer and a calculator into one sidebar +tool (`lib/sidebar/calculator-tab.svelte`). It replaced the standalone +designer view/route from Phases 17/18. All numeric math lives in +`pkg/calc` and is reached through the `Core` WASM bridge; the calculator +holds input state and orchestrates, it never computes. + +## Modes + +- **Calculator** (`ship`): the full tool — design area, derived results, + planet build, goal-seek. +- **Modernization**: reuses the design area and shows per-block and + total `BlockUpgradeCost` from the current tech to an editable target + tech. The design-area component is extracted + (`lib/calculator/ship-design-area.svelte`) so the future ship-group + upgrade flow can reuse it. + +The `path` mode from the original plan was dropped (MVP path-finding is +brute force); reach circles on the map replace it. `bombing` is folded +in as a per-ship result rather than a separate mode. + +## Areas + +1. **Ship Class design area** — five blocks (drive, armament, weapons, + shields, cargo) and four tech levels (drive, weapons, shields, + cargo). Tech defaults to the player's current tech and shows a lock + icon once overridden; clicking it resets to the default. +2. **Calculator area** — derived results: empty/loaded mass, empty/ + loaded speed, attack, defence, bombing (per ship), cargo capacity. + A load toggle (empty / full / custom) sets the cargo load that the + loaded-column results use. +3. **Planet area** — when an own planet is selected on the map, shows + its MAT (overridable) and the single-turn build rate (ships per turn, + turns per ship). The realistic multi-turn forecast with CAP/COL + supply is Phase 34. + +## Locks and goal-seek + +Two distinct lock semantics share one icon (a closed padlock; it only +appears once a value is pinned, click to release): + +- **Override locks** on inputs that have a default — the four techs and + the planet MAT. Editing one overrides the default; the lock resets it. + Any number may be overridden at once. +- **Goal-seek locks** on derived results. Pinning a result back-solves + the single input it claims, which then renders read-only (computed): + + | result | claims | + | ------------- | ------------- | + | attack | weapons block | + | defence | shields block | + | empty speed | drive block | + | loaded speed | drive block | + | empty mass | cargo block | + | loaded mass | cargo load | + + Only **one** result may be locked at a time (the others' lock + affordances disable with a tooltip). An unreachable target — e.g. a + speed at or above the stripped-hull ceiling `20 × driveTech`, or a + solved block that fails the value rules — leaves the locked cell in a + red error state and does not apply. Inverse solving lives in + `pkg/calc/solve.go`; the bisection for defence → shields is the only + non-analytic case. + +## Create / load / delete + +The name field is a combobox over the player's existing classes. Picking +an existing class loads it as a template (so you can tweak and Create a +new one); Create is disabled while the name is invalid or duplicate +(reusing `lib/util/ship-class-validation.ts`). When a saved class is +loaded, a Delete affordance appears. Create / Delete reuse the existing +`createShipClass` / `removeShipClass` order-draft flow, so the optimistic +overlay reflects the change immediately. Ship classes are immutable after +creation (per `game/rules.txt`), so there is no edit — only Create-new +and Delete. + +## Reach circles + +When an own planet is selected in calculator mode, the calculator +publishes the planet origin and the design's loaded speed to a shared +store (`lib/calculator/reach.svelte`). The map view +(`lib/active-view/map.svelte`) reads it and draws 1–3 thin concentric +reach circles (`map/reach-circles.ts`) for 1/2/3 turns. The ring count +shrinks as speed grows: a ring is dropped once the previous one reaches +the torus wrap-midpoint (half the shorter side) or the no-wrap map edge +(farthest corner). The circles clear when the selection clears or the +design is invalid. + +## State preservation and history + +Calculator inputs are component-local state. The sidebar keeps the tab +mounted while the player navigates between active views, so inputs +persist across view switches per the global state-preservation rule +(`ui/docs/navigation.md`). Tech levels track the rendered report, so in +history mode the calculator computes against the viewed snapshot's tech. + +The ship-classes table and the view/bottom menus open the calculator via +a shared request store (`lib/calculator/load-request.svelte`): the +in-game layout flips the sidebar to the calculator tab and the +calculator loads the requested class (or starts a fresh design). + +## Layout and mobile + +Everything stacks vertically to fit the 18 rem sidebar; the design and +result rows use compact two-column (ship/tech, empty/loaded) grids. On +mobile the sidebar is the existing bottom-sheet/overlay; the calc bottom +tab opens it. + +## Runtime note + +The new bridge functions are only present after `make wasm` rebuilds +`ui/frontend/static/core.wasm` (needs TinyGo). Vitest injects a fake +`Core` (`tests/fake-core.ts`) mirroring `pkg/calc`, so unit/component +tests do not need the rebuild; the Playwright suite and the live app do. diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md index 75e4cf0..0ca04c8 100644 --- a/ui/docs/navigation.md +++ b/ui/docs/navigation.md @@ -27,16 +27,17 @@ separate dispatch component. | `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | Phase 23 | | `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 | | `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 | -| `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) | | `/games/:id/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` | Phase 21 | `/games/:id` (no trailing view) redirects to `/games/:id/map`. The -optional `:classId?` / `:scienceId?` segments on the designer -routes match SvelteKit's `[[classId]]` syntax — `/designer/ship-class` -opens the empty new-class form, `/designer/ship-class/{name}` -opens the read-only view of the named class with the Delete -affordance. Phase 17 lights up the ship-class CRUD path; Phase 18 -adds the live `pkg/calc/`-backed preview pane on top. +optional `:scienceId?` segment on the science designer route matches +SvelteKit's `[[scienceId]]` syntax — `/designer/science` opens the +empty new-science form, `/designer/science/{name}` opens the named +science. Phase 17/18 originally added a parallel ship-class designer +route; Phase 30 removed it and folded ship-class design into the +sidebar ship-class calculator (`lib/sidebar/calculator-tab.svelte`, +see [calculator-ux.md](calculator-ux.md)), reached from the +ship-classes table and the view/bottom menus. The `entity` slug on the table route is kebab-case (`planets`, `ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`). @@ -163,10 +164,11 @@ Tables, History, Settings, Logout) is the polish target for Phase 35 ## Transient map overlays Some views can push a transient overlay onto `/map` with a back -affordance — for example, the ship-class designer pushes a -range-preview overlay onto the map. The transient overlay clears -when the user navigates to any other view via the header or the -bottom-tabs. +affordance. (Phase 30's calculator reach circles are a simpler, +always-on map extra rather than a back-stacked overlay; the transient +back-stack mechanism itself is still a Phase 34 concept.) A transient +overlay clears when the user navigates to any other view via the header +or the bottom-tabs. Phase 10 documents this concept but does not implement the back-stack mechanism. Phase 34 lands the back-stack alongside its diff --git a/ui/frontend/src/lib/active-view/designer-ship-class.svelte b/ui/frontend/src/lib/active-view/designer-ship-class.svelte deleted file mode 100644 index 5414bb8..0000000 --- a/ui/frontend/src/lib/active-view/designer-ship-class.svelte +++ /dev/null @@ -1,573 +0,0 @@ - - - -
- {#if isViewMode} - {#if viewing === null} -

{i18n.t("game.view.designer.ship_class")}

-

- {i18n.t("game.designer.ship_class.not_found", { name: classId })} -

-
- -
- {:else} -

- {i18n.t("game.designer.ship_class.title.view", { name: viewing.name })} -

-

- {i18n.t("game.designer.ship_class.read_only_notice")} -

-
-
-
{i18n.t("game.designer.ship_class.field.name")}
-
{viewing.name}
-
-
-
{i18n.t("game.designer.ship_class.field.drive")}
-
- {formatNumber(viewing.drive)} -
-
-
-
{i18n.t("game.designer.ship_class.field.armament")}
-
- {viewing.armament} -
-
-
-
{i18n.t("game.designer.ship_class.field.weapons")}
-
- {formatNumber(viewing.weapons)} -
-
-
-
{i18n.t("game.designer.ship_class.field.shields")}
-
- {formatNumber(viewing.shields)} -
-
-
-
{i18n.t("game.designer.ship_class.field.cargo")}
-
- {formatNumber(viewing.cargo)} -
-
-
-
- - -
- {/if} - {:else} -

- {i18n.t("game.designer.ship_class.title.new")} -

-

- {i18n.t("game.designer.ship_class.hint.values")} -

-
{ - event.preventDefault(); - void save(); - }} - > - - - - - - - {#if !validation.ok} -

- {invalidMessage} -

- {/if} - {#if preview !== null} - - {/if} -
- - -
-
- {/if} -
- - diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 6b0ec37..721175f 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -31,6 +31,8 @@ preference the store already manages. } from "../../map/index"; import { buildCargoRouteLines } from "../../map/cargo-routes"; import { buildPendingSendLines } from "../../map/pending-send-routes"; + import { computeReachCircles } from "../../map/reach-circles"; + import { reachStore } from "$lib/calculator/reach.svelte"; import { reportToWorld, type HitTarget, @@ -196,6 +198,11 @@ preference the store already manages. void toggles.bombingMarkers; void toggles.visibleHyperspace; + // Subscribe to the calculator's published reach so the rings + // redraw as the design or the selected planet changes. + void reachStore.origin; + void reachStore.speedPerTurn; + // Phase 29 visibility derivation. Cargo routes and pending- // Send overlay are extras (no Pixi remount on flip); the // cascade-filtering happens here so the extras list shrinks @@ -219,8 +226,14 @@ preference the store already manages. // the visible set reliably triggers a push. const draftCommands = orderDraft?.commands ?? []; const draftStatuses = orderDraft?.statuses ?? {}; + const reachOrigin = reachStore.origin; + const reachFingerprint = + reachOrigin === null + ? "" + : `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`; const extrasFingerprint = `cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` + + `reach=${reachFingerprint}|` + computeRoutesFingerprint(report.routes) + "|" + computePendingSendFingerprint(draftCommands, draftStatuses); @@ -256,6 +269,7 @@ preference the store already manages. draftStatuses, toggles, hiddenPlanetNumbers, + mode, ), ); }); @@ -289,6 +303,7 @@ preference the store already manages. draftStatuses: Readonly>, toggles: MapToggles, hiddenPlanetNumbers: ReadonlySet, + mode: "torus" | "no-wrap", ): import("../../map/world").Primitive[] { const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined; const cargo = toggles.cargoRoutes @@ -300,7 +315,21 @@ preference the store already manages. draftStatuses, skip ? { skipPlanets: skip } : undefined, ); - return [...cargo, ...pending]; + // Reach circles published by the ship-class calculator. Empty + // when no own planet is selected or the design is invalid, so + // this is a no-op for the rest of the map. + const reachOrigin = reachStore.origin; + const reach = + reachOrigin !== null && reachStore.speedPerTurn > 0 + ? computeReachCircles( + reachOrigin, + reachStore.speedPerTurn, + report.mapWidth, + report.mapHeight, + mode, + ) + : []; + return [...cargo, ...pending, ...reach]; } function applyVisibilityState( @@ -342,6 +371,7 @@ preference the store already manages. draftStatuses, toggles, hiddenPlanetNumbers, + mode, ), ); lastExtrasFingerprint = extrasFingerprint; diff --git a/ui/frontend/src/lib/active-view/table-ship-classes.svelte b/ui/frontend/src/lib/active-view/table-ship-classes.svelte index 3a723bb..7473976 100644 --- a/ui/frontend/src/lib/active-view/table-ship-classes.svelte +++ b/ui/frontend/src/lib/active-view/table-ship-classes.svelte @@ -14,8 +14,6 @@ data fetching is performed here — the layout is responsible. --> + +
+
+ + {i18n.t("game.calculator.col.ship")} + {i18n.t("game.calculator.col.tech")} +
+ {#each BLOCK_ROWS as row (row.key)} + {@const isComputed = computedInput === row.key} +
+ {row.label()} + {#if isComputed} + + {:else} + + {/if} + {#if row.tech !== null} + {@const techKey = row.tech} + + onTechInput(techKey)} + data-testid={`calculator-tech-${techKey}`} + /> + {#if techOverridden[techKey]} + + {/if} + + {:else} + + {/if} +
+ {/each} +
+ + diff --git a/ui/frontend/src/lib/header/view-menu.svelte b/ui/frontend/src/lib/header/view-menu.svelte index 30f6c99..6069f9e 100644 --- a/ui/frontend/src/lib/header/view-menu.svelte +++ b/ui/frontend/src/lib/header/view-menu.svelte @@ -135,14 +135,6 @@ polishes microcopy. {/if} - - + + {:else} + + {fmt(value)} + + + {/if} +{/snippet} + +
+
+ + +
+ +
+ (loadedExisting = null)} + onchange={() => loadExisting(name)} + aria-invalid={nameValidation.ok ? "false" : "true"} + data-testid="calculator-name" + /> + + {#each localShipClass as cls (cls.name)} + + {/each} + + {#if mode === "ship"} + + {/if} +
+ {#if mode === "ship" && canDelete} + + {/if} + + + + {#if mode === "ship"} +
+ {i18n.t("game.calculator.load.label")} +
+ {#each LOAD_MODES as m (m)} + + {/each} +
+ {#if loadMode === "custom"} + + {/if} +
+ +
+
+ + {i18n.t("game.calculator.col.empty")} + {i18n.t("game.calculator.col.loaded")} +
+
+ {i18n.t("game.calculator.out.mass")} + {@render lockable("emptyMass", result.outputs?.emptyMass)} + {@render lockable("loadedMass", result.outputs?.loadedMass)} +
+
+ {i18n.t("game.calculator.out.speed")} + {@render lockable("speedEmpty", result.outputs?.speedEmpty)} + {@render lockable("speedLoaded", result.outputs?.speedLoaded)} +
+
+ {i18n.t("game.calculator.out.attack")} + {@render lockable("attack", result.outputs?.attack)} + +
+
+ {i18n.t("game.calculator.out.defense")} + {@render lockable("defense", result.outputs?.defense)} + +
+
+ {i18n.t("game.calculator.out.bombing")} + + + {fmt(result.outputs?.bombing)} + + + +
+
+ {i18n.t("game.calculator.out.cargo_capacity")} + + + {fmt(result.outputs === null ? null : result.cargoCapacity)} + + + +
+
+ +
+ {#if selectedPlanet === null} +

+ {i18n.t("game.calculator.planet.none")} +

+ {:else} +

+ {i18n.t("game.calculator.planet.label", { + name: selectedPlanet.name, + number: String(selectedPlanet.number), + })} +

+
+ {i18n.t("game.calculator.planet.mat")} + + + {#if matOverridden} + + {/if} + + +
+
+
+
{i18n.t("game.calculator.planet.ships_per_turn")}
+
+ {fmt(planetBuild?.shipsPerTurn)} +
+
+
+
{i18n.t("game.calculator.planet.turns_per_ship")}
+
+ {fmt(planetBuild?.turnsPerShip ?? null)} +
+
+
+ {/if} +
+ {:else} +
+
+ + {i18n.t("game.calculator.modern.target")} + {i18n.t("game.calculator.modern.cost")} +
+ {#each modernCosts?.perBlock ?? [] as row (row.key)} +
+ {i18n.t(`game.calculator.field.${row.key}` as TranslationKey)} + + + + + + {fmt(row.cost)} + + +
+ {/each} +
+ {i18n.t("game.calculator.modern.total")} + + + + {fmt(modernCosts?.total)} + + +
+
+ {/if}
diff --git a/ui/frontend/src/lib/util/ship-class-validation.ts b/ui/frontend/src/lib/util/ship-class-validation.ts index d01f425..67e3a8e 100644 --- a/ui/frontend/src/lib/util/ship-class-validation.ts +++ b/ui/frontend/src/lib/util/ship-class-validation.ts @@ -1,7 +1,7 @@ // TS port of `pkg/calc/validator.go.ValidateShipTypeValues` plus a // thin wrapper that runs the entity-name rules and a duplicate-name // check against the live `localShipClass` projection. The validator -// is reused by the ship-class designer (`active-view/designer-ship-class.svelte`) +// is reused by the ship-class calculator (`sidebar/calculator-tab.svelte`) // for inline error messages and by `OrderDraftStore.validateCommand` // to gate auto-sync, so the local invariants match the engine's // (`game/internal/controller/ship_class.go.ShipClassCreate`). @@ -33,9 +33,12 @@ import { * translation keys for those branches and adds new keys only for * the value-derived ones. */ -export type ShipClassInvalidReason = - | EntityNameInvalidReason - | "duplicate_name" +/** + * ShipClassValueInvalidReason enumerates the value-only refusals (no + * name rules). The ship-class calculator validates the five blocks + * independently of the name, so it consumes this narrower union. + */ +export type ShipClassValueInvalidReason = | "drive_value" | "armament_value" | "armament_not_integer" @@ -45,6 +48,11 @@ export type ShipClassInvalidReason = | "armament_weapons_pair" | "all_zero"; +export type ShipClassInvalidReason = + | EntityNameInvalidReason + | "duplicate_name" + | ShipClassValueInvalidReason; + /** * ShipClassDraft is the structural shape the designer composes. The * five numeric fields carry the player's typed values verbatim; @@ -60,10 +68,17 @@ export interface ShipClassDraft { cargo: number; } +/** ShipClassValues is the five-block subset validated by value rules. */ +export type ShipClassValues = Omit; + export type ShipClassValidation = | { ok: true; value: ShipClassDraft } | { ok: false; reason: ShipClassInvalidReason }; +export type ShipClassValuesValidation = + | { ok: true } + | { ok: false; reason: ShipClassValueInvalidReason }; + /** * validateShipClass mirrors `ValidateShipTypeValues` plus the * entity-name rules. `existingNames` is the optimistic projection of @@ -84,38 +99,9 @@ export function validateShipClass( } const trimmedName = nameResult.value; - if (!isValidDWSC(draft.drive)) { - return { ok: false, reason: "drive_value" }; - } - if (!Number.isFinite(draft.armament) || draft.armament < 0) { - return { ok: false, reason: "armament_value" }; - } - if (!Number.isInteger(draft.armament)) { - return { ok: false, reason: "armament_not_integer" }; - } - if (!isValidDWSC(draft.weapons)) { - return { ok: false, reason: "weapons_value" }; - } - if (!isValidDWSC(draft.shields)) { - return { ok: false, reason: "shields_value" }; - } - if (!isValidDWSC(draft.cargo)) { - return { ok: false, reason: "cargo_value" }; - } - if ( - (draft.armament === 0 && draft.weapons !== 0) || - (draft.armament !== 0 && draft.weapons === 0) - ) { - return { ok: false, reason: "armament_weapons_pair" }; - } - if ( - draft.drive === 0 && - draft.armament === 0 && - draft.weapons === 0 && - draft.shields === 0 && - draft.cargo === 0 - ) { - return { ok: false, reason: "all_zero" }; + const valueResult = validateShipClassValues(draft); + if (!valueResult.ok) { + return { ok: false, reason: valueResult.reason }; } const existing = options.existingNames ?? []; @@ -129,6 +115,51 @@ export function validateShipClass( }; } +/** + * validateShipClassValues runs only the five-block value rules from + * `pkg/calc/validator.go.ValidateShipTypeValues`, independent of the + * name. The ship-class calculator gates its live previews on this so a + * blank or in-progress name does not suppress the math. + */ +export function validateShipClassValues( + values: ShipClassValues, +): ShipClassValuesValidation { + if (!isValidDWSC(values.drive)) { + return { ok: false, reason: "drive_value" }; + } + if (!Number.isFinite(values.armament) || values.armament < 0) { + return { ok: false, reason: "armament_value" }; + } + if (!Number.isInteger(values.armament)) { + return { ok: false, reason: "armament_not_integer" }; + } + if (!isValidDWSC(values.weapons)) { + return { ok: false, reason: "weapons_value" }; + } + if (!isValidDWSC(values.shields)) { + return { ok: false, reason: "shields_value" }; + } + if (!isValidDWSC(values.cargo)) { + return { ok: false, reason: "cargo_value" }; + } + if ( + (values.armament === 0 && values.weapons !== 0) || + (values.armament !== 0 && values.weapons === 0) + ) { + return { ok: false, reason: "armament_weapons_pair" }; + } + if ( + values.drive === 0 && + values.armament === 0 && + values.weapons === 0 && + values.shields === 0 && + values.cargo === 0 + ) { + return { ok: false, reason: "all_zero" }; + } + return { ok: true }; +} + /** * isValidDWSC mirrors `pkg/calc/validator.go.CheckShipTypeValueDWSC`: * a Drive / Weapons / Shields / Cargo value is acceptable only when diff --git a/ui/frontend/src/map/reach-circles.ts b/ui/frontend/src/map/reach-circles.ts new file mode 100644 index 0000000..670d679 --- /dev/null +++ b/ui/frontend/src/map/reach-circles.ts @@ -0,0 +1,81 @@ +// Phase 30 reach circles. When the ship-class calculator has a planet +// selected and a valid design, it publishes the design's loaded speed and +// the planet origin to `lib/calculator/reach.svelte`; the map view reads +// that store and feeds it through `computeReachCircles` to draw 1–3 thin +// concentric rings showing how far the ship reaches in 1, 2, and 3 turns. +// +// The ring count is bounded by how soon a ring reaches the meaningful +// extent of the map: half the shorter side on a torus (beyond that a +// ring wraps onto itself), or the farthest corner on a bounded no-wrap +// plane (beyond that the ring is entirely off-map). A fast ship that +// clears the map in one turn therefore shows a single ring; a slow ship +// shows all three. + +import type { CirclePrim } from "./world"; + +export const REACH_CIRCLE_COLOR = 0x6d8cff; +/** High-bit prefix so reach-circle ids never collide with planet + * numbers, cargo-route lines, or battle/bombing markers. */ +export const REACH_CIRCLE_ID_PREFIX = 0xb0000000; +const MAX_TURNS = 3; +/** Reach rings sit below every interactive primitive so they never win + * a click against a planet or ship group. */ +const REACH_CIRCLE_PRIORITY = 0; + +/** + * reachBound returns the largest ring radius worth drawing for the map. + * On a torus it is half the shorter side (a larger ring overlaps itself); + * on a bounded plane it is the distance from the origin to the farthest + * corner (a larger ring is wholly off-map). + */ +export function reachBound( + origin: { x: number; y: number }, + mapWidth: number, + mapHeight: number, + mode: "torus" | "no-wrap", +): number { + if (mode === "torus") { + return Math.min(mapWidth, mapHeight) / 2; + } + const dx = Math.max(origin.x, mapWidth - origin.x); + const dy = Math.max(origin.y, mapHeight - origin.y); + return Math.hypot(dx, dy); +} + +/** + * computeReachCircles produces up to three concentric ring primitives + * centred on `origin`, with radii speedPerTurn × {1, 2, 3}. A ring for + * turn `t` is included only when the previous ring still fits inside the + * map's reach bound, so the count shrinks as the per-turn speed grows. + * Returns an empty list when the speed is non-positive. + */ +export function computeReachCircles( + origin: { x: number; y: number }, + speedPerTurn: number, + mapWidth: number, + mapHeight: number, + mode: "torus" | "no-wrap", +): CirclePrim[] { + if (speedPerTurn <= 0) return []; + const bound = reachBound(origin, mapWidth, mapHeight, mode); + const circles: CirclePrim[] = []; + for (let turn = 1; turn <= MAX_TURNS; turn++) { + // Stop once the previous ring already reached the bound. + if (turn > 1 && speedPerTurn * (turn - 1) >= bound) break; + circles.push({ + kind: "circle", + id: REACH_CIRCLE_ID_PREFIX + turn, + priority: REACH_CIRCLE_PRIORITY, + hitSlopPx: 0, + x: origin.x, + y: origin.y, + radius: speedPerTurn * turn, + style: { + strokeColor: REACH_CIRCLE_COLOR, + strokeAlpha: 0.55 - (turn - 1) * 0.12, + strokeWidthPx: 1, + }, + }); + } + return circles; +} diff --git a/ui/frontend/src/platform/core/index.ts b/ui/frontend/src/platform/core/index.ts index c604b5c..8d23b9f 100644 --- a/ui/frontend/src/platform/core/index.ts +++ b/ui/frontend/src/platform/core/index.ts @@ -79,6 +79,72 @@ export interface BlockUpgradeCostInput { targetTech: number; } +export interface EffectiveAttackInput { + weapons: number; + weaponsTech: number; +} + +export interface EffectiveDefenceInput { + shields: number; + shieldsTech: number; + fullMass: number; +} + +export interface BombingPowerInput { + weapons: number; + weaponsTech: number; + armament: number; + number: number; +} + +export interface ShipBuildCostInput { + shipMass: number; + material: number; + resources: number; +} + +export interface ProduceShipsInTurnInput { + productionAvailable: number; + material: number; + resources: number; + shipMass: number; +} + +export interface ProduceShipsInTurnResult { + ships: number; + materialLeft: number; + productionUsed: number; + progress: number; +} + +export interface WeaponsForAttackInput { + targetAttack: number; + weaponsTech: number; +} + +export interface DriveForSpeedInput { + targetSpeed: number; + driveTech: number; + restMass: number; +} + +export interface ShieldsForDefenceInput { + targetDefence: number; + shieldsTech: number; + restMass: number; +} + +export interface CargoForEmptyMassInput { + targetEmptyMass: number; + restMass: number; +} + +export interface LoadForFullMassInput { + targetFullMass: number; + emptyMass: number; + cargoTech: number; +} + export interface Core { /** * signRequest returns the canonical signing input bytes for a v1 @@ -174,6 +240,77 @@ export interface Core { * preview. */ blockUpgradeCost(input: BlockUpgradeCostInput): number; + + /** + * effectiveAttack wraps `pkg/calc/ship.go.EffectiveAttack`: combat + * attack power = weapons block × weapons tech. + */ + effectiveAttack(input: EffectiveAttackInput): number; + + /** + * effectiveDefence wraps `pkg/calc/ship.go.EffectiveDefence`: combat + * defence power = shields × shields tech, normalised by the cube root + * of full mass; zero when fullMass ≤ 0. + */ + effectiveDefence(input: EffectiveDefenceInput): number; + + /** + * bombingPower wraps `pkg/calc/ship.go.BombingPower`: planet-bombing + * power of `number` ships. The calculator passes number = 1 for a + * per-ship reading. + */ + bombingPower(input: BombingPowerInput): number; + + /** + * shipBuildCost wraps `pkg/calc/planet.go.ShipBuildCost`: the per-turn + * production cost of one ship of empty mass shipMass on a planet + * holding `material` at the `resources` rating. + */ + shipBuildCost(input: ShipBuildCostInput): number; + + /** + * produceShipsInTurn wraps `pkg/calc/planet.go.ProduceShipsInTurn`: + * one turn of ship production, returning whole ships completed, the + * material left, the production spent on the next (incomplete) ship, + * and that ship's progress fraction. Matches the engine's per-turn + * build loop. + */ + produceShipsInTurn(input: ProduceShipsInTurnInput): ProduceShipsInTurnResult; + + /** + * weaponsForAttack wraps `pkg/calc/solve.go.WeaponsForAttack`: the + * weapons block that yields the target attack power, or null when the + * request is infeasible. + */ + weaponsForAttack(input: WeaponsForAttackInput): number | null; + + /** + * driveForSpeed wraps `pkg/calc/solve.go.DriveForSpeed`: the drive + * block that yields the target speed given the rest of the ship's + * mass, or null when the target is at/above the stripped-hull ceiling. + */ + driveForSpeed(input: DriveForSpeedInput): number | null; + + /** + * shieldsForDefence wraps `pkg/calc/solve.go.ShieldsForDefence`: the + * shields block that yields the target defence given the rest of the + * ship's mass (found by bisection), or null when infeasible. + */ + shieldsForDefence(input: ShieldsForDefenceInput): number | null; + + /** + * cargoForEmptyMass wraps `pkg/calc/solve.go.CargoForEmptyMass`: the + * cargo block that brings empty mass to the target, or null when the + * target is below the fixed block mass. + */ + cargoForEmptyMass(input: CargoForEmptyMassInput): number | null; + + /** + * loadForFullMass wraps `pkg/calc/solve.go.LoadForFullMass`: the cargo + * load that brings full mass to the target, or null when the target is + * below the empty mass. + */ + loadForFullMass(input: LoadForFullMassInput): number | null; } export type CoreLoader = () => Promise; diff --git a/ui/frontend/src/platform/core/wasm.ts b/ui/frontend/src/platform/core/wasm.ts index 499bb26..88749d6 100644 --- a/ui/frontend/src/platform/core/wasm.ts +++ b/ui/frontend/src/platform/core/wasm.ts @@ -10,17 +10,28 @@ import type { BlockUpgradeCostInput, + BombingPowerInput, CargoCapacityInput, + CargoForEmptyMassInput, CarryingMassInput, Core, DriveEffectiveInput, + DriveForSpeedInput, + EffectiveAttackInput, + EffectiveDefenceInput, EventSigningFields, FullMassInput, + LoadForFullMassInput, + ProduceShipsInTurnInput, + ProduceShipsInTurnResult, RequestSigningFields, ResponseSigningFields, ShipBlocksInput, + ShipBuildCostInput, + ShieldsForDefenceInput, SpeedInput, WeaponsBlockInput, + WeaponsForAttackInput, } from "./index"; /** @@ -52,6 +63,16 @@ interface GalaxyCoreBridge { cargoCapacity(input: CargoCapacityInput): number; carryingMass(input: CarryingMassInput): number; blockUpgradeCost(input: BlockUpgradeCostInput): number; + effectiveAttack(input: EffectiveAttackInput): number; + effectiveDefence(input: EffectiveDefenceInput): number; + bombingPower(input: BombingPowerInput): number; + shipBuildCost(input: ShipBuildCostInput): number; + produceShipsInTurn(input: ProduceShipsInTurnInput): ProduceShipsInTurnResult; + weaponsForAttack(input: WeaponsForAttackInput): number | null; + driveForSpeed(input: DriveForSpeedInput): number | null; + shieldsForDefence(input: ShieldsForDefenceInput): number | null; + cargoForEmptyMass(input: CargoForEmptyMassInput): number | null; + loadForFullMass(input: LoadForFullMassInput): number | null; } interface BridgeRequestFields { @@ -215,6 +236,36 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core { blockUpgradeCost(input: BlockUpgradeCostInput): number { return bridge.blockUpgradeCost(input); }, + effectiveAttack(input: EffectiveAttackInput): number { + return bridge.effectiveAttack(input); + }, + effectiveDefence(input: EffectiveDefenceInput): number { + return bridge.effectiveDefence(input); + }, + bombingPower(input: BombingPowerInput): number { + return bridge.bombingPower(input); + }, + shipBuildCost(input: ShipBuildCostInput): number { + return bridge.shipBuildCost(input); + }, + produceShipsInTurn(input: ProduceShipsInTurnInput): ProduceShipsInTurnResult { + return bridge.produceShipsInTurn(input); + }, + weaponsForAttack(input: WeaponsForAttackInput): number | null { + return bridge.weaponsForAttack(input); + }, + driveForSpeed(input: DriveForSpeedInput): number | null { + return bridge.driveForSpeed(input); + }, + shieldsForDefence(input: ShieldsForDefenceInput): number | null { + return bridge.shieldsForDefence(input); + }, + cargoForEmptyMass(input: CargoForEmptyMassInput): number | null { + return bridge.cargoForEmptyMass(input); + }, + loadForFullMass(input: LoadForFullMassInput): number | null { + return bridge.loadForFullMass(input); + }, }; } diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte index 558d2e0..c647b9a 100644 --- a/ui/frontend/src/routes/games/[id]/+layout.svelte +++ b/ui/frontend/src/routes/games/[id]/+layout.svelte @@ -61,6 +61,7 @@ fresh. SelectionStore, SELECTION_CONTEXT_KEY, } from "$lib/selection.svelte"; + import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte"; import { createRenderedReportSource, RENDERED_REPORT_CONTEXT_KEY, @@ -226,6 +227,17 @@ fresh. sidebarOpen = true; }); + // Reveal the calculator whenever the ship-classes table or the + // bottom-tabs entry asks to load a class (or start a fresh design). + let lastCalcLoadToken = 0; + $effect(() => { + const token = calculatorLoadRequest.token; + if (token === lastCalcLoadToken) return; + lastCalcLoadToken = token; + activeTab = "calculator"; + sidebarOpen = true; + }); + function toggleSidebar(): void { sidebarOpen = !sidebarOpen; } diff --git a/ui/frontend/src/routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte b/ui/frontend/src/routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte deleted file mode 100644 index 212c8cc..0000000 --- a/ui/frontend/src/routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/ui/frontend/static/core.wasm b/ui/frontend/static/core.wasm index 95ed1a1..3718e16 100644 Binary files a/ui/frontend/static/core.wasm and b/ui/frontend/static/core.wasm differ diff --git a/ui/frontend/tests/calc-model.test.ts b/ui/frontend/tests/calc-model.test.ts new file mode 100644 index 0000000..92e877d --- /dev/null +++ b/ui/frontend/tests/calc-model.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + computeCalculator, + computePlanetBuild, + type CalculatorInput, +} from "../src/lib/calculator/calc-model"; +import { makeFakeCore } from "./fake-core"; + +function input(overrides: Partial = {}): CalculatorInput { + return { + blocks: { drive: 10, armament: 0, weapons: 0, shields: 5, cargo: 5 }, + driveTech: 1.2, + weaponsTech: 1.5, + shieldsTech: 1, + cargoTech: 1, + loadMode: "full", + customLoad: 0, + lock: null, + ...overrides, + }; +} + +describe("computeCalculator forward", () => { + test("returns null outputs without a Core", () => { + const result = computeCalculator(input(), null); + expect(result.outputs).toBeNull(); + expect(result.valuesValid).toBe(false); + }); + + test("computes outputs for a valid design", () => { + const core = makeFakeCore(); + const result = computeCalculator(input(), core); + expect(result.valuesValid).toBe(true); + expect(result.outputs).not.toBeNull(); + // empty mass = drive + shields + cargo = 20 (no weapons block). + expect(result.outputs?.emptyMass).toBeCloseTo(20, 9); + // cargo capacity = 1 * (5 + 25/20) = 6.25, full load mass = 26.25. + expect(result.load).toBeCloseTo(6.25, 9); + expect(result.outputs?.loadedMass).toBeCloseTo(26.25, 9); + }); + + test("hides outputs when blocks are invalid (armament without weapons)", () => { + const core = makeFakeCore(); + const result = computeCalculator( + input({ blocks: { drive: 10, armament: 3, weapons: 0, shields: 5, cargo: 5 } }), + core, + ); + expect(result.valuesValid).toBe(false); + expect(result.valueReason).toBe("armament_weapons_pair"); + expect(result.outputs).toBeNull(); + }); + + test("empty load mode yields loaded mass equal to empty mass", () => { + const core = makeFakeCore(); + const result = computeCalculator(input({ loadMode: "empty" }), core); + expect(result.load).toBe(0); + expect(result.outputs?.loadedMass).toBeCloseTo( + result.outputs?.emptyMass ?? -1, + 9, + ); + }); +}); + +describe("computeCalculator goal-seek", () => { + test("attack lock back-solves the weapons block", () => { + const core = makeFakeCore(); + const result = computeCalculator( + input({ blocks: { drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 5 }, lock: { output: "attack", value: 30 } }), + core, + ); + expect(result.lockFeasible).toBe(true); + expect(result.computedInput).toBe("weapons"); + // weapons = 30 / weaponsTech(1.5) = 20. + expect(result.blocks.weapons).toBeCloseTo(20, 9); + expect(result.outputs?.attack).toBeCloseTo(30, 6); + }); + + test("loaded-speed lock back-solves the drive block", () => { + const core = makeFakeCore(); + const result = computeCalculator( + input({ lock: { output: "speedLoaded", value: 5 } }), + core, + ); + expect(result.lockFeasible).toBe(true); + expect(result.computedInput).toBe("drive"); + expect(result.outputs?.speedLoaded).toBeCloseTo(5, 6); + }); + + test("defence lock back-solves the shields block", () => { + const core = makeFakeCore(); + const result = computeCalculator( + input({ lock: { output: "defense", value: 4 } }), + core, + ); + expect(result.lockFeasible).toBe(true); + expect(result.computedInput).toBe("shields"); + expect(result.outputs?.defense).toBeCloseTo(4, 5); + }); + + test("empty-mass lock back-solves the cargo block", () => { + const core = makeFakeCore(); + const result = computeCalculator( + input({ lock: { output: "emptyMass", value: 25 } }), + core, + ); + expect(result.computedInput).toBe("cargo"); + // cargo = 25 - (drive 10 + shields 5) = 10. + expect(result.blocks.cargo).toBeCloseTo(10, 9); + expect(result.outputs?.emptyMass).toBeCloseTo(25, 9); + }); + + test("loaded-mass lock back-solves the cargo load", () => { + const core = makeFakeCore(); + const result = computeCalculator( + input({ lock: { output: "loadedMass", value: 30 } }), + core, + ); + expect(result.computedInput).toBe("load"); + // load = (30 - emptyMass 20) * cargoTech 1 = 10. + expect(result.load).toBeCloseTo(10, 9); + expect(result.outputs?.loadedMass).toBeCloseTo(30, 9); + }); + + test("an unreachable speed marks the lock infeasible", () => { + const core = makeFakeCore(); + const result = computeCalculator( + // ceiling is 20 * driveTech = 24; 100 is unreachable. + input({ lock: { output: "speedEmpty", value: 100 } }), + core, + ); + expect(result.lockFeasible).toBe(false); + expect(result.computedInput).toBeNull(); + // the claimed block keeps its raw value. + expect(result.blocks.drive).toBe(10); + }); + + test("calls the matching solver with the right context", () => { + const weaponsForAttack = vi.fn(() => 7); + const core = makeFakeCore({ weaponsForAttack }); + const result = computeCalculator( + input({ blocks: { drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 5 }, lock: { output: "attack", value: 30 } }), + core, + ); + expect(weaponsForAttack).toHaveBeenCalledWith({ + targetAttack: 30, + weaponsTech: 1.5, + }); + expect(result.blocks.weapons).toBe(7); + expect(result.computedInput).toBe("weapons"); + }); +}); + +describe("computePlanetBuild", () => { + test("returns null without a Core", () => { + expect(computePlanetBuild({ shipMass: 10, freeIndustry: 100, material: 0, resources: 10 }, null)).toBeNull(); + }); + + test("derives ships-per-turn from the per-turn build loop", () => { + const core = makeFakeCore(); + // shipMass 1, ample material: 100 production / (10 per ship) = 10 ships. + const result = computePlanetBuild( + { shipMass: 1, freeIndustry: 100, material: 100, resources: 10 }, + core, + ); + expect(result?.wholeShips).toBe(10); + expect(result?.shipsPerTurn).toBeCloseTo(10, 9); + expect(result?.turnsPerShip).toBeCloseTo(0.1, 9); + }); + + test("reports turns-per-ship when under one ship per turn", () => { + const core = makeFakeCore(); + // shipMass 10, no material, resources 0.5: cost 120, 60 production → 0.5 ship. + const result = computePlanetBuild( + { shipMass: 10, freeIndustry: 60, material: 0, resources: 0.5 }, + core, + ); + expect(result?.wholeShips).toBe(0); + expect(result?.shipsPerTurn).toBeCloseTo(0.5, 9); + expect(result?.turnsPerShip).toBeCloseTo(2, 9); + }); +}); diff --git a/ui/frontend/tests/calculator-tab.test.ts b/ui/frontend/tests/calculator-tab.test.ts new file mode 100644 index 0000000..4251705 --- /dev/null +++ b/ui/frontend/tests/calculator-tab.test.ts @@ -0,0 +1,207 @@ +// Component coverage for the Phase 30 ship-class calculator: forward +// results, single-target goal-seek wired through a mounted component, the +// Create flow against a real OrderDraftStore, and the planet area. The +// math itself is covered by `calc-model.test.ts` and the Go parity tests; +// here we assert the component renders and orchestrates them. + +import "@testing-library/jest-dom/vitest"; +import "fake-indexeddb/auto"; +import { fireEvent, render } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { i18n } from "../src/lib/i18n/index.svelte"; +import CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte"; +import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte"; +import { + ORDER_DRAFT_CONTEXT_KEY, + OrderDraftStore, +} from "../src/sync/order-draft.svelte"; +import { + SELECTION_CONTEXT_KEY, + SelectionStore, +} from "../src/lib/selection.svelte"; +import { + RENDERED_REPORT_CONTEXT_KEY, + type RenderedReportSource, +} from "../src/lib/rendered-report.svelte"; +import type { GameReport, ReportPlanet } from "../src/api/game-state"; +import type { Core } from "../src/platform/core/index"; +import { makeFakeCore } from "./fake-core"; +import { IDBCache } from "../src/platform/store/idb-cache"; +import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; +import type { IDBPDatabase } from "idb"; + +const GAME_ID = "11111111-2222-3333-4444-555555555555"; + +let db: IDBPDatabase; +let dbName: string; +let draft: OrderDraftStore; + +const LOCAL_PLANET: ReportPlanet = { + number: 17, + name: "Castle", + x: 100, + y: 100, + kind: "local", + owner: "me", + size: 1000, + resources: 10, + industryStockpile: 0, + materialsStockpile: 100, + industry: 1000, + population: 1000, + colonists: 0, + production: "Cruiser", + freeIndustry: 1000, +}; + +function makeReport(over: Partial = {}): GameReport { + return { + localPlayerDrive: 1.2, + localPlayerWeapons: 1.5, + localPlayerShields: 1, + localPlayerCargo: 1, + localShipClass: [], + planets: [], + ...over, + } as unknown as GameReport; +} + +function mount(opts: { + core?: Core | null; + report?: GameReport; + selection?: SelectionStore; +} = {}) { + const holder = new CoreHolder(); + holder.set(opts.core === undefined ? makeFakeCore() : opts.core); + const selection = opts.selection ?? new SelectionStore(); + const report = opts.report ?? makeReport(); + const source: RenderedReportSource = { + get report() { + return report; + }, + }; + const context = new Map([ + [RENDERED_REPORT_CONTEXT_KEY, source], + [ORDER_DRAFT_CONTEXT_KEY, draft], + [CORE_CONTEXT_KEY, holder], + [SELECTION_CONTEXT_KEY, selection], + ]); + return render(CalculatorTab, { context }); +} + +async function setBlock( + ui: { getByTestId(id: string): HTMLElement }, + key: string, + value: number, +): Promise { + await fireEvent.input(ui.getByTestId(`calculator-block-${key}`), { + target: { value: String(value) }, + }); +} + +beforeEach(async () => { + dbName = `galaxy-calculator-${crypto.randomUUID()}`; + db = await openGalaxyDB(dbName); + draft = new OrderDraftStore(); + await draft.init({ cache: new IDBCache(db), gameId: GAME_ID }); + i18n.resetForTests("en"); +}); + +afterEach(async () => { + draft.dispose(); + db.close(); + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +describe("calculator-tab", () => { + test("computes results once the blocks are valid", async () => { + const ui = mount(); + // All-zero blocks are invalid: results read as unavailable. + expect(ui.getByTestId("calculator-out-emptyMass")).toHaveTextContent("—"); + await setBlock(ui, "drive", 10); + await setBlock(ui, "shields", 5); + await setBlock(ui, "cargo", 5); + // empty mass = 10 + 5 + 5 = 20. + expect(ui.getByTestId("calculator-out-emptyMass")).toHaveTextContent("20"); + }); + + test("locking attack back-solves the weapons block", async () => { + const ui = mount(); + await setBlock(ui, "drive", 10); + await setBlock(ui, "armament", 2); + await setBlock(ui, "weapons", 5); + await setBlock(ui, "shields", 5); + await setBlock(ui, "cargo", 5); + await fireEvent.click(ui.getByTestId("calculator-lock-attack")); + await fireEvent.input(ui.getByTestId("calculator-locked-attack"), { + target: { value: "30" }, + }); + // weapons = 30 / weaponsTech(1.5) = 20, shown read-only. + const weapons = ui.getByTestId("calculator-block-weapons"); + expect(weapons).toHaveValue(20); + expect(weapons).toHaveAttribute("readonly"); + }); + + test("flags an unreachable speed target as infeasible", async () => { + const ui = mount(); + await setBlock(ui, "drive", 10); + await setBlock(ui, "shields", 5); + await setBlock(ui, "cargo", 5); + await fireEvent.click(ui.getByTestId("calculator-lock-speedEmpty")); + // ceiling is 20 * driveTech(1.2) = 24; 100 is unreachable. + await fireEvent.input(ui.getByTestId("calculator-locked-speedEmpty"), { + target: { value: "100" }, + }); + const locked = ui.getByTestId("calculator-locked-speedEmpty"); + expect(locked).toHaveAttribute("title", expect.stringMatching(/cannot be reached/i)); + }); + + test("create adds a ship-class command once the name is valid", async () => { + const ui = mount(); + await setBlock(ui, "drive", 10); + await setBlock(ui, "shields", 5); + await setBlock(ui, "cargo", 5); + const create = ui.getByTestId("calculator-create"); + expect(create).toBeDisabled(); + await fireEvent.input(ui.getByTestId("calculator-name"), { + target: { value: "Cruiser" }, + }); + expect(create).not.toBeDisabled(); + await fireEvent.click(create); + expect(draft.commands).toHaveLength(1); + expect(draft.commands[0]).toMatchObject({ + kind: "createShipClass", + name: "Cruiser", + drive: 10, + shields: 5, + cargo: 5, + }); + }); + + test("planet area prompts for a selection when none is active", () => { + const ui = mount(); + expect(ui.getByTestId("calculator-planet-none")).toBeInTheDocument(); + }); + + test("planet area shows build stats for a selected own planet", async () => { + const selection = new SelectionStore(); + selection.selectPlanet(17); + const ui = mount({ + report: makeReport({ planets: [LOCAL_PLANET] }), + selection, + }); + await setBlock(ui, "drive", 10); + await setBlock(ui, "shields", 5); + await setBlock(ui, "cargo", 5); + expect(ui.getByTestId("calculator-planet-name")).toHaveTextContent("Castle"); + expect( + ui.getByTestId("calculator-ships-per-turn"), + ).not.toHaveTextContent("—"); + }); +}); diff --git a/ui/frontend/tests/designer-ship-class.test.ts b/ui/frontend/tests/designer-ship-class.test.ts deleted file mode 100644 index 16942f6..0000000 --- a/ui/frontend/tests/designer-ship-class.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -// Vitest coverage for the Phase 17 ship-class designer. Drives the -// component against a real `OrderDraftStore` (with `fake-indexeddb` -// standing in for the browser's IDB factory) so the local-validation -// + auto-sync side-effects are exercised end-to-end. The optimistic -// overlay arrives through a synthetic `RenderedReportSource` instead -// of a live report so the tests do not have to thread a full -// `GameStateStore` boot. - -import "@testing-library/jest-dom/vitest"; -import "fake-indexeddb/auto"; -import { fireEvent, render, waitFor } from "@testing-library/svelte"; -import { - afterEach, - beforeEach, - describe, - expect, - test, - vi, -} from "vitest"; - -import { i18n } from "../src/lib/i18n/index.svelte"; -import type { GameReport, ShipClassSummary } from "../src/api/game-state"; -import { - ORDER_DRAFT_CONTEXT_KEY, - OrderDraftStore, -} from "../src/sync/order-draft.svelte"; -import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte"; -import { - CORE_CONTEXT_KEY, - type CoreHandle, -} from "../src/lib/core-context.svelte"; -import { loadWasmCoreForTest } from "./setup-wasm"; -import type { Core } from "../src/platform/core/index"; -import { IDBCache } from "../src/platform/store/idb-cache"; -import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; -import type { Cache } from "../src/platform/store/index"; -import type { IDBPDatabase } from "idb"; -import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; - -const GAME_ID = "11111111-2222-3333-4444-555555555555"; - -const pageMock = vi.hoisted(() => ({ - url: new URL("http://localhost/games/g1/designer/ship-class"), - params: { id: "g1" } as Record, -})); - -const gotoMock = vi.hoisted(() => vi.fn()); - -vi.mock("$app/state", () => ({ - page: pageMock, -})); - -vi.mock("$app/navigation", () => ({ - goto: gotoMock, -})); - -import DesignerShipClass from "../src/lib/active-view/designer-ship-class.svelte"; - -let db: IDBPDatabase; -let dbName: string; -let cache: Cache; -let draft: OrderDraftStore; - -beforeEach(async () => { - dbName = `galaxy-designer-${crypto.randomUUID()}`; - db = await openGalaxyDB(dbName); - cache = new IDBCache(db); - draft = new OrderDraftStore(); - await draft.init({ cache, gameId: GAME_ID }); - i18n.resetForTests("en"); - pageMock.params = { id: "g1" }; - gotoMock.mockClear(); -}); - -afterEach(async () => { - draft.dispose(); - db.close(); - await new Promise((resolve) => { - const req = indexedDB.deleteDatabase(dbName); - req.onsuccess = () => resolve(); - req.onerror = () => resolve(); - req.onblocked = () => resolve(); - }); -}); - -function shipClass( - overrides: Partial & Pick, -): ShipClassSummary { - return { - drive: 0, - armament: 0, - weapons: 0, - shields: 0, - cargo: 0, - ...overrides, - }; -} - -function makeReport(localShipClass: ShipClassSummary[] = []): GameReport { - return { - turn: 1, - mapWidth: 1000, - mapHeight: 1000, - planetCount: 0, - planets: [], - race: "", - localShipClass, - routes: [], - localPlayerDrive: 0, - localPlayerWeapons: 0, - localPlayerShields: 0, - localPlayerCargo: 0, - ...EMPTY_SHIP_GROUPS, - }; -} - -function mountDesigner(opts: { - classId?: string; - report?: GameReport | null; - core?: Core | null; -}) { - const report = opts.report ?? makeReport(); - pageMock.params = opts.classId - ? { id: "g1", classId: opts.classId } - : { id: "g1" }; - const renderedReport = { get report() { return report; } }; - const coreHandle: CoreHandle = { core: opts.core ?? null }; - const context = new Map([ - [ORDER_DRAFT_CONTEXT_KEY, draft], - [RENDERED_REPORT_CONTEXT_KEY, renderedReport], - [CORE_CONTEXT_KEY, coreHandle], - ]); - return render(DesignerShipClass, { context }); -} - -describe("ship-class designer (new mode)", () => { - test("renders the form with a Save button disabled by default", () => { - const ui = mountDesigner({}); - expect( - ui.getByTestId("active-view-designer-ship-class"), - ).toHaveAttribute("data-mode", "new"); - expect(ui.getByTestId("designer-ship-class-save")).toBeDisabled(); - expect(ui.getByTestId("designer-ship-class-error")).toHaveTextContent( - "name cannot be empty", - ); - }); - - test("Save adds a createShipClass to the draft after a valid edit", async () => { - const ui = mountDesigner({}); - const nameInput = ui.getByTestId("designer-ship-class-input-name"); - await fireEvent.input(nameInput, { target: { value: "Drone" } }); - const driveInput = ui.getByTestId("designer-ship-class-input-drive"); - await fireEvent.input(driveInput, { target: { value: "1" } }); - - await waitFor(() => - expect(ui.getByTestId("designer-ship-class-save")).not.toBeDisabled(), - ); - await fireEvent.click(ui.getByTestId("designer-ship-class-save")); - await waitFor(() => expect(draft.commands).toHaveLength(1)); - const cmd = draft.commands[0]!; - if (cmd.kind !== "createShipClass") throw new Error("wrong kind"); - expect(cmd.name).toBe("Drone"); - expect(cmd.drive).toBe(1); - expect(cmd.armament).toBe(0); - await waitFor(() => - expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/ship-classes"), - ); - }); - - test("rejects a duplicate name from the overlay before any sync", async () => { - const ui = mountDesigner({ - report: makeReport([ - shipClass({ name: "Scout", drive: 1 }), - ]), - }); - await fireEvent.input( - ui.getByTestId("designer-ship-class-input-name"), - { target: { value: "Scout" } }, - ); - await fireEvent.input( - ui.getByTestId("designer-ship-class-input-drive"), - { target: { value: "1" } }, - ); - await waitFor(() => - expect(ui.getByTestId("designer-ship-class-error")).toHaveTextContent( - "already exists", - ), - ); - expect(ui.getByTestId("designer-ship-class-save")).toBeDisabled(); - }); - - test("rejects nonzero armament with zero weapons", async () => { - const ui = mountDesigner({}); - await fireEvent.input( - ui.getByTestId("designer-ship-class-input-name"), - { target: { value: "Bad" } }, - ); - await fireEvent.input( - ui.getByTestId("designer-ship-class-input-armament"), - { target: { value: "1" } }, - ); - await fireEvent.input( - ui.getByTestId("designer-ship-class-input-drive"), - { target: { value: "1" } }, - ); - await waitFor(() => - expect(ui.getByTestId("designer-ship-class-error")).toHaveTextContent( - "armament and weapons must be both zero or both nonzero", - ), - ); - }); - - test("Cancel navigates back without mutating the draft", async () => { - const ui = mountDesigner({}); - await fireEvent.click(ui.getByTestId("designer-ship-class-cancel")); - expect(draft.commands).toHaveLength(0); - expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/ship-classes"); - }); -}); - -describe("ship-class designer (view mode)", () => { - test("renders the read-only summary plus Delete + Back affordances", () => { - const ui = mountDesigner({ - classId: "Cruiser", - report: makeReport([ - shipClass({ - name: "Cruiser", - drive: 15, - armament: 1, - weapons: 15, - shields: 15, - cargo: 0, - }), - ]), - }); - expect( - ui.getByTestId("active-view-designer-ship-class"), - ).toHaveAttribute("data-mode", "view"); - expect(ui.getByTestId("designer-ship-class-view-name")).toHaveTextContent( - "Cruiser", - ); - expect(ui.getByTestId("designer-ship-class-view-drive")).toHaveTextContent( - "15", - ); - expect( - ui.getByTestId("designer-ship-class-view-armament"), - ).toHaveTextContent("1"); - expect(ui.getByTestId("designer-ship-class-delete")).toBeInTheDocument(); - expect(ui.getByTestId("designer-ship-class-back")).toBeInTheDocument(); - }); - - test("Delete adds a removeShipClass and navigates back", async () => { - const ui = mountDesigner({ - classId: "Cruiser", - report: makeReport([shipClass({ name: "Cruiser", drive: 15 })]), - }); - await fireEvent.click(ui.getByTestId("designer-ship-class-delete")); - await waitFor(() => expect(draft.commands).toHaveLength(1)); - const cmd = draft.commands[0]!; - if (cmd.kind !== "removeShipClass") throw new Error("wrong kind"); - expect(cmd.name).toBe("Cruiser"); - await waitFor(() => - expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/ship-classes"), - ); - }); - - test("renders a not-found message when the class is missing from the overlay", () => { - const ui = mountDesigner({ - classId: "Ghost", - report: makeReport([]), - }); - expect( - ui.getByTestId("designer-ship-class-not-found"), - ).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, - ...EMPTY_SHIP_GROUPS, - }; - 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, - ...EMPTY_SHIP_GROUPS, - }; - 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"), - ); - }); -}); diff --git a/ui/frontend/tests/e2e/game-shell-inspector.spec.ts b/ui/frontend/tests/e2e/game-shell-inspector.spec.ts index eee38c0..d1ddf0e 100644 --- a/ui/frontend/tests/e2e/game-shell-inspector.spec.ts +++ b/ui/frontend/tests/e2e/game-shell-inspector.spec.ts @@ -227,3 +227,54 @@ test("clicking a planet on mobile raises the bottom-sheet, close clears it", asy await page.getByTestId("inspector-planet-sheet-close").click(); await expect(page.getByTestId("inspector-planet-sheet")).toHaveCount(0); }); + +// Counts reach-circle primitives off the renderer debug surface. Reach +// circles use ids in [REACH_CIRCLE_ID_PREFIX, bombing-marker prefix) — +// 0xb0000000..0xc0000000 (see `map/reach-circles.ts`). +async function countReachCircles(page: Page): Promise { + return page.evaluate(() => { + const surface = ( + window as unknown as { + __galaxyDebug?: { + getMapPrimitives?: () => readonly { id: number; kind: string }[]; + }; + } + ).__galaxyDebug; + const prims = surface?.getMapPrimitives?.() ?? []; + return prims.filter( + (p) => p.kind === "circle" && p.id >= 0xb0000000 && p.id < 0xc0000000, + ).length; + }); +} + +test("calculator draws reach circles for the selected planet", async ({ + page, +}, testInfo) => { + test.skip( + testInfo.project.name.startsWith("chromium-mobile"), + "calculator + reach circles are a desktop-sidebar flow", + ); + await setupShell(page); + + // No reach circles before a planet is selected and a design exists. + expect(await countReachCircles(page)).toBe(0); + + // Select the planet, then switch the sidebar to the calculator. + await clickCanvasCentre(page); + await page.getByTestId("sidebar-tab-calculator").click(); + const calc = page.getByTestId("sidebar-tool-calculator"); + await expect(calc).toBeVisible(); + + // A valid design with a positive drive tech override yields a + // positive loaded speed, which the calculator publishes to the map. + await calc.getByTestId("calculator-block-drive").fill("10"); + await calc.getByTestId("calculator-block-shields").fill("5"); + await calc.getByTestId("calculator-block-cargo").fill("5"); + await calc.getByTestId("calculator-tech-drive").fill("1.2"); + + await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0); + + // Leaving ship mode clears the published reach, so the rings drop. + await calc.getByTestId("calculator-mode-modernization").click(); + await expect.poll(() => countReachCircles(page)).toBe(0); +}); diff --git a/ui/frontend/tests/e2e/game-shell.spec.ts b/ui/frontend/tests/e2e/game-shell.spec.ts index 4aa9c12..becbf2a 100644 --- a/ui/frontend/tests/e2e/game-shell.spec.ts +++ b/ui/frontend/tests/e2e/game-shell.spec.ts @@ -54,11 +54,6 @@ test("header view-menu navigates to every active view", async ({ page }) => { ["view-menu-item-report", "active-view-report", "/report"], ["view-menu-item-mail", "active-view-mail", "/mail"], ["view-menu-item-battle", "active-view-battle", "/battle"], - [ - "view-menu-item-designer-ship-class", - "active-view-designer-ship-class", - "/designer/ship-class", - ], [ "view-menu-item-designer-science", "active-view-designer-science", diff --git a/ui/frontend/tests/e2e/map-roundtrip.spec.ts b/ui/frontend/tests/e2e/map-roundtrip.spec.ts index d39e496..451d755 100644 --- a/ui/frontend/tests/e2e/map-roundtrip.spec.ts +++ b/ui/frontend/tests/e2e/map-roundtrip.spec.ts @@ -161,7 +161,6 @@ async function readPrimitiveCount(page: Page): Promise { const NON_MAP_VIEWS: ReadonlyArray<{ label: string; testid: string }> = [ { label: "report", testid: "view-menu-item-report" }, - { label: "designer-ship-class", testid: "view-menu-item-designer-ship-class" }, { label: "designer-science", testid: "view-menu-item-designer-science" }, { label: "mail", testid: "view-menu-item-mail" }, ]; diff --git a/ui/frontend/tests/e2e/ship-classes.spec.ts b/ui/frontend/tests/e2e/ship-classes.spec.ts index bdc5445..a1bb11f 100644 --- a/ui/frontend/tests/e2e/ship-classes.spec.ts +++ b/ui/frontend/tests/e2e/ship-classes.spec.ts @@ -1,8 +1,8 @@ -// Phase 17 end-to-end coverage for the ship-class CRUD flow. Boots +// Phase 30 end-to-end coverage for the ship-class CRUD flow. Boots // an authenticated session, mocks the gateway with a single local // planet plus an empty `localShipClass` projection, navigates to -// the ship-classes table, opens the designer, fills the form, and -// asserts that: +// the ship-classes table, opens the sidebar calculator, fills the +// design, and asserts that: // // 1. Save adds a `createShipClass` row to the local order draft, // auto-syncs through `user.games.order`, and the new class @@ -254,12 +254,12 @@ async function bootSession(page: Page): Promise { ); } -test("create / list / delete ship class via the table + designer", async ({ +test("create / list / delete ship class via the table + calculator", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), - "phase 17 spec covers desktop layout; mobile inherits the same store", + "phase 30 spec covers desktop layout; mobile inherits the same store", ); const handle = await mockGateway(page, { createOutcome: "applied" }); @@ -270,19 +270,18 @@ test("create / list / delete ship class via the table + designer", async ({ await expect(tableHost).toBeVisible(); await expect(page.getByTestId("ship-classes-empty")).toBeVisible(); + // "New" opens the calculator in the sidebar with a fresh design. await page.getByTestId("ship-classes-new").click(); - await expect(page.getByTestId("active-view-designer-ship-class")).toHaveAttribute( - "data-mode", - "new", - ); + const calc = page.getByTestId("sidebar-tool-calculator"); + await expect(calc).toBeVisible(); - await page.getByTestId("designer-ship-class-input-name").fill("Drone"); - await page.getByTestId("designer-ship-class-input-drive").fill("1"); - const save = page.getByTestId("designer-ship-class-save"); - await expect(save).toBeEnabled(); - await save.click(); + await calc.getByTestId("calculator-name").fill("Drone"); + await calc.getByTestId("calculator-block-drive").fill("1"); + const create = calc.getByTestId("calculator-create"); + await expect(create).toBeEnabled(); + await create.click(); - // Returns to the table; the optimistic overlay shows the new class. + // The table's optimistic overlay shows the new class. await expect(page.getByTestId("ship-classes-table")).toBeVisible(); const row = page.getByTestId("ship-classes-row"); await expect(row).toHaveAttribute("data-name", "Drone"); @@ -312,38 +311,33 @@ test("create / list / delete ship class via the table + designer", async ({ expect(handle.lastRemove?.name).toBe("Drone"); }); -test("designer keeps Save disabled while the form is invalid", async ({ +test("calculator keeps Create disabled while the design is invalid", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), - "phase 17 spec covers desktop layout; mobile inherits the same store", + "phase 30 spec covers desktop layout; mobile inherits the same store", ); await mockGateway(page, { createOutcome: "applied" }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/designer/ship-class`); + await page.goto(`/games/${GAME_ID}/table/ship-classes`); + await page.getByTestId("ship-classes-new").click(); + const calc = page.getByTestId("sidebar-tool-calculator"); + const create = calc.getByTestId("calculator-create"); - const save = page.getByTestId("designer-ship-class-save"); - await expect(save).toBeDisabled(); + // Empty name + all-zero blocks: Create is disabled. + await expect(create).toBeDisabled(); - // Empty name surfaces the entity-name error. - await expect(page.getByTestId("designer-ship-class-error")).toHaveText( - "name cannot be empty", - ); + // Mismatched armament / weapons keeps it disabled (pair rule). + await calc.getByTestId("calculator-name").fill("Bad"); + await calc.getByTestId("calculator-block-armament").fill("1"); + await calc.getByTestId("calculator-block-drive").fill("1"); + await expect(create).toBeDisabled(); - // Mismatched armament / weapons triggers the pair rule. - await page.getByTestId("designer-ship-class-input-name").fill("Bad"); - await page.getByTestId("designer-ship-class-input-armament").fill("1"); - await page.getByTestId("designer-ship-class-input-drive").fill("1"); - await expect(page.getByTestId("designer-ship-class-error")).toHaveText( - "armament and weapons must be both zero or both nonzero", - ); - await expect(save).toBeDisabled(); - - // Filling weapons resolves the pair rule. - await page.getByTestId("designer-ship-class-input-weapons").fill("1"); - await expect(save).toBeEnabled(); + // Filling weapons resolves the pair rule and enables Create. + await calc.getByTestId("calculator-block-weapons").fill("1"); + await expect(create).toBeEnabled(); }); test("rejected createShipClass keeps the table empty and surfaces the failure", async ({ @@ -351,23 +345,24 @@ test("rejected createShipClass keeps the table empty and surfaces the failure", }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), - "phase 17 spec covers desktop layout; mobile inherits the same store", + "phase 30 spec covers desktop layout; mobile inherits the same store", ); await mockGateway(page, { createOutcome: "rejected" }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/designer/ship-class`); + await page.goto(`/games/${GAME_ID}/table/ship-classes`); + await page.getByTestId("ship-classes-new").click(); + const calc = page.getByTestId("sidebar-tool-calculator"); - await page.getByTestId("designer-ship-class-input-name").fill("Drone"); - await page.getByTestId("designer-ship-class-input-drive").fill("1"); - await page.getByTestId("designer-ship-class-save").click(); + await calc.getByTestId("calculator-name").fill("Drone"); + await calc.getByTestId("calculator-block-drive").fill("1"); + await calc.getByTestId("calculator-create").click(); - // Designer's save() calls SvelteKit `goto` to navigate back to - // the table. SPA navigation keeps the per-game `OrderDraftStore` - // alive so the auto-sync round-trip (which flips the status from - // `submitting` to `rejected`) lands while the table is showing. - // Order tab carries a `rejected` row; the optimistic overlay - // drops the class once the engine answers `cmdApplied=false`. + // Create stays in the table active view (the calculator is a + // sidebar tool). The per-game OrderDraftStore drives the auto-sync + // round-trip, which flips the status to `rejected`; the order tab + // carries a `rejected` row and the overlay drops the class once the + // engine answers cmdApplied=false. await page.getByTestId("sidebar-tab-order").click(); const orderTool = page.getByTestId("sidebar-tool-order"); await expect(orderTool.getByTestId("order-command-status-0")).toHaveText( diff --git a/ui/frontend/tests/fake-core.ts b/ui/frontend/tests/fake-core.ts new file mode 100644 index 0000000..6e33f7a --- /dev/null +++ b/ui/frontend/tests/fake-core.ts @@ -0,0 +1,128 @@ +// makeFakeCore builds a complete `Core` whose calc methods mirror +// `pkg/calc` exactly, for component and unit tests that must not boot the +// real WASM module. The committed `core.wasm` is rebuilt out-of-band +// (`make wasm`, needs TinyGo), so tests that exercise calculator math +// inject this fake instead of depending on a freshly built binary. The +// Go parity tests in `ui/core/calc` guarantee the real bridge agrees with +// `pkg/calc`, so a fake that also mirrors `pkg/calc` stays faithful. +// +// Pass `overrides` to replace individual methods — e.g. `vi.fn()` spies +// when a test wants to assert how the calc-model orchestrates the bridge. + +import type { Core } from "../src/platform/core/index"; + +function weaponsBlockMass(weapons: number, armament: number): number | null { + if ((armament === 0 && weapons !== 0) || (armament !== 0 && weapons === 0)) { + return null; + } + return (armament + 1) * (weapons / 2); +} + +export function makeFakeCore(overrides: Partial = {}): Core { + const base: Core = { + signRequest: () => new Uint8Array(), + verifyResponse: () => true, + verifyEvent: () => true, + verifyPayloadHash: () => true, + driveEffective: ({ drive, driveTech }) => drive * driveTech, + emptyMass: ({ drive, weapons, armament, shields, cargo }) => { + const wb = weaponsBlockMass(weapons, armament); + if (wb === null) return null; + return drive + shields + cargo + wb; + }, + weaponsBlockMass: ({ weapons, armament }) => + weaponsBlockMass(weapons, armament), + fullMass: ({ emptyMass, carryingMass }) => emptyMass + carryingMass, + speed: ({ driveEffective, fullMass }) => + fullMass <= 0 ? 0 : (driveEffective * 20) / fullMass, + cargoCapacity: ({ cargo, cargoTech }) => + cargoTech * (cargo + (cargo * cargo) / 20), + carryingMass: ({ load, cargoTech }) => (load <= 0 ? 0 : load / cargoTech), + blockUpgradeCost: ({ blockMass, currentTech, targetTech }) => + blockMass === 0 || targetTech <= currentTech + ? 0 + : (1 - currentTech / targetTech) * 10 * blockMass, + effectiveAttack: ({ weapons, weaponsTech }) => weapons * weaponsTech, + effectiveDefence: ({ shields, shieldsTech, fullMass }) => + fullMass <= 0 + ? 0 + : ((shields * shieldsTech) / Math.cbrt(fullMass)) * Math.cbrt(30), + bombingPower: ({ weapons, weaponsTech, armament, number }) => + (Math.sqrt(weapons * weaponsTech) / 10 + 1) * + weapons * + weaponsTech * + armament * + number, + shipBuildCost: ({ shipMass, material, resources }) => { + const matNeed = Math.max(0, shipMass - material); + const matFarm = resources > 0 ? matNeed / resources : 0; + return shipMass * 10 + matFarm; + }, + produceShipsInTurn: ({ + productionAvailable, + material, + resources, + shipMass, + }) => { + if (productionAvailable <= 0 || shipMass <= 0) { + return { + ships: 0, + materialLeft: material, + productionUsed: 0, + progress: 0, + }; + } + let pa = productionAvailable; + let mat = material; + let ships = 0; + for (;;) { + const matNeed = Math.max(0, shipMass - mat); + const cost = shipMass * 10 + (resources > 0 ? matNeed / resources : 0); + if (pa < cost) { + return { + ships, + materialLeft: mat, + productionUsed: pa, + progress: pa / cost, + }; + } + pa -= cost; + mat = mat - shipMass + matNeed; + ships += 1; + } + }, + weaponsForAttack: ({ targetAttack, weaponsTech }) => + weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech, + driveForSpeed: ({ targetSpeed, driveTech, restMass }) => { + const ceiling = 20 * driveTech; + if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) { + return null; + } + return (targetSpeed * restMass) / (ceiling - targetSpeed); + }, + shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => { + if (targetDefence <= 0 || shieldsTech <= 0) return null; + const def = (s: number) => + ((s * shieldsTech) / Math.cbrt(s + restMass)) * Math.cbrt(30); + let lo = 0; + let hi = 1; + while (def(hi) < targetDefence) { + hi *= 2; + if (hi > 1e12) return null; + } + for (let i = 0; i < 100; i++) { + const mid = (lo + hi) / 2; + if (def(mid) < targetDefence) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; + }, + cargoForEmptyMass: ({ targetEmptyMass, restMass }) => + targetEmptyMass - restMass < 0 ? null : targetEmptyMass - restMass, + loadForFullMass: ({ targetFullMass, emptyMass, cargoTech }) => + cargoTech <= 0 || targetFullMass < emptyMass + ? null + : (targetFullMass - emptyMass) * cargoTech, + }; + return { ...base, ...overrides }; +} diff --git a/ui/frontend/tests/galaxy-client.test.ts b/ui/frontend/tests/galaxy-client.test.ts index 718e6be..7cf1244 100644 --- a/ui/frontend/tests/galaxy-client.test.ts +++ b/ui/frontend/tests/galaxy-client.test.ts @@ -25,6 +25,7 @@ import type { RequestSigningFields, ResponseSigningFields, } from "../src/platform/core/index"; +import { makeFakeCore } from "./fake-core"; const FIXED_REQUEST_ID = "req-test-1"; const FIXED_TIMESTAMP = 1_700_000_000_000n; @@ -204,19 +205,13 @@ function mockCore(opts: MockCoreOptions): Core & { verifyEvent: ReturnType; } { return { + // `GalaxyClient` does not exercise the calc bridge, so the calc + // methods come from the shared fake; only the signing/verify + // methods need spies for the orchestration-order assertions. + ...makeFakeCore(), signRequest: vi.fn(opts.signRequestImpl), verifyResponse: vi.fn(opts.verifyResponseImpl), verifyEvent: vi.fn(() => true), verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl), - // `GalaxyClient` does not exercise the Phase 18 calc bridge, - // so these stubs only need to satisfy the `Core` interface. - driveEffective: () => 0, - emptyMass: () => 0, - weaponsBlockMass: () => 0, - fullMass: () => 0, - speed: () => 0, - cargoCapacity: () => 0, - carryingMass: () => 0, - blockUpgradeCost: () => 0, }; } diff --git a/ui/frontend/tests/game-shell-header.test.ts b/ui/frontend/tests/game-shell-header.test.ts index 6dde9a9..f0f61bb 100644 --- a/ui/frontend/tests/game-shell-header.test.ts +++ b/ui/frontend/tests/game-shell-header.test.ts @@ -138,10 +138,6 @@ describe("game-shell header", () => { ["view-menu-item-report", "/games/g1/report"], ["view-menu-item-battle", "/games/g1/battle"], ["view-menu-item-mail", "/games/g1/mail"], - [ - "view-menu-item-designer-ship-class", - "/games/g1/designer/ship-class", - ], [ "view-menu-item-designer-science", "/games/g1/designer/science", diff --git a/ui/frontend/tests/game-shell-stubs.test.ts b/ui/frontend/tests/game-shell-stubs.test.ts index de9004d..6b27a80 100644 --- a/ui/frontend/tests/game-shell-stubs.test.ts +++ b/ui/frontend/tests/game-shell-stubs.test.ts @@ -2,11 +2,12 @@ // stub renders the localised view title plus the `coming soon` body // copy and exposes a stable `data-testid` so later phases can replace // the content without renaming the test hook. Phase 17 lit up the -// ship-classes table and the ship-class designer; Phase 21 lit up -// the sciences table and the science designer. Their assertions -// moved to dedicated suites (`table-ship-classes.test.ts`, -// `designer-ship-class.test.ts`, `table-sciences.test.ts`, -// `designer-science.test.ts`); the `table.svelte` router still falls +// ship-classes table (Phase 30 folded the designer into the sidebar +// calculator); Phase 21 lit up the sciences table and the science +// designer. Their assertions moved to dedicated suites +// (`table-ship-classes.test.ts`, `calculator-tab.test.ts`, +// `table-sciences.test.ts`, `designer-science.test.ts`); the +// `table.svelte` router still falls // back to the stub for the remaining entities (planets, ship-groups, // fleets, races) and that fallback is exercised here. diff --git a/ui/frontend/tests/inspector-ship-group-modernize-cost.test.ts b/ui/frontend/tests/inspector-ship-group-modernize-cost.test.ts index b4c5807..c0e502a 100644 --- a/ui/frontend/tests/inspector-ship-group-modernize-cost.test.ts +++ b/ui/frontend/tests/inspector-ship-group-modernize-cost.test.ts @@ -25,6 +25,7 @@ import { } from "../src/sync/order-draft.svelte"; import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte"; import type { Core } from "../src/platform/core/index"; +import { makeFakeCore } from "./fake-core"; import { IDBCache } from "../src/platform/store/idb-cache"; import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; import type { Cache } from "../src/platform/store/index"; @@ -107,34 +108,12 @@ function group( }; } -// stubCore mirrors `pkg/calc/ship.go.BlockUpgradeCost` exactly so the -// preview line shows the same number the WASM bridge would produce. -// The other Core methods are no-ops because the modernize preview -// only consults `weaponsBlockMass` (returns null when armament is -// zero) and `blockUpgradeCost`. +// stubCore mirrors `pkg/calc` exactly (via the shared makeFakeCore) so +// the preview line shows the same number the WASM bridge would produce. +// The modernize preview only consults `weaponsBlockMass` (returns null +// when armament is zero) and `blockUpgradeCost`. function stubCore(): Core { - return { - signRequest: () => new Uint8Array(), - verifyResponse: () => true, - verifyEvent: () => true, - verifyPayloadHash: () => true, - driveEffective: ({ drive, driveTech }) => drive * driveTech, - emptyMass: () => 0, - weaponsBlockMass: ({ weapons, armament }) => { - if ((armament === 0 && weapons !== 0) || (armament !== 0 && weapons === 0)) { - return null; - } - return (armament + 1) * (weapons / 2); - }, - fullMass: ({ emptyMass, carryingMass }) => emptyMass + carryingMass, - speed: () => 0, - cargoCapacity: () => 0, - carryingMass: () => 0, - blockUpgradeCost: ({ blockMass, currentTech, targetTech }) => { - if (blockMass === 0 || targetTech <= currentTech) return 0; - return (1 - currentTech / targetTech) * 10 * blockMass; - }, - }; + return makeFakeCore(); } function mount( diff --git a/ui/frontend/tests/reach-circles.test.ts b/ui/frontend/tests/reach-circles.test.ts new file mode 100644 index 0000000..06e5211 --- /dev/null +++ b/ui/frontend/tests/reach-circles.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "vitest"; + +import { + computeReachCircles, + reachBound, + REACH_CIRCLE_ID_PREFIX, +} from "../src/map/reach-circles"; + +const CENTER = { x: 500, y: 500 }; + +describe("computeReachCircles", () => { + test("no circles for a non-positive speed", () => { + expect(computeReachCircles(CENTER, 0, 1000, 1000, "torus")).toEqual([]); + expect(computeReachCircles(CENTER, -5, 1000, 1000, "torus")).toEqual([]); + }); + + test("torus: a slow ship shows all three rings", () => { + // bound = min(1000,1000)/2 = 500; speed 100 keeps every ring inside. + const circles = computeReachCircles(CENTER, 100, 1000, 1000, "torus"); + expect(circles.map((c) => c.radius)).toEqual([100, 200, 300]); + expect(circles[0].id).toBe(REACH_CIRCLE_ID_PREFIX + 1); + expect(circles[0].style.strokeColor).toBeDefined(); + }); + + test("torus: a ship reaching the wrap midpoint shows one ring", () => { + // speed 500 hits the bound on turn 1, so turn 2 is dropped. + const circles = computeReachCircles(CENTER, 500, 1000, 1000, "torus"); + expect(circles).toHaveLength(1); + expect(circles[0].radius).toBe(500); + }); + + test("torus: a mid-speed ship shows two rings", () => { + // speed 300: ring 1 = 300 (< 500), ring 2 = 600; ring 3 dropped + // because 2 × 300 = 600 ≥ 500. + const circles = computeReachCircles(CENTER, 300, 1000, 1000, "torus"); + expect(circles.map((c) => c.radius)).toEqual([300, 600]); + }); + + test("no-wrap: the bound is the farthest corner", () => { + // origin at a corner → farthest corner is the diagonal. + expect(reachBound({ x: 0, y: 0 }, 1000, 1000, "no-wrap")).toBeCloseTo( + Math.hypot(1000, 1000), + 6, + ); + const circles = computeReachCircles( + { x: 0, y: 0 }, + 500, + 1000, + 1000, + "no-wrap", + ); + // bound ≈ 1414, so all three rings fit. + expect(circles.map((c) => c.radius)).toEqual([500, 1000, 1500]); + }); +}); diff --git a/ui/frontend/tests/table-ship-classes.test.ts b/ui/frontend/tests/table-ship-classes.test.ts index 427c448..3a59ac2 100644 --- a/ui/frontend/tests/table-ship-classes.test.ts +++ b/ui/frontend/tests/table-ship-classes.test.ts @@ -23,6 +23,7 @@ import { OrderDraftStore, } from "../src/sync/order-draft.svelte"; import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte"; +import { calculatorLoadRequest } from "../src/lib/calculator/load-request.svelte"; import { IDBCache } from "../src/platform/store/idb-cache"; import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; import type { Cache } from "../src/platform/store/index"; @@ -188,12 +189,14 @@ describe("ship-classes table", () => { expect(names).toEqual(["Battleship", "Cruiser", "Drone"]); }); - test("dblclick on a row navigates to the designer for that class", async () => { + test("dblclick on a row requests the calculator for that class", async () => { const ui = mountTable( makeReport([shipClass({ name: "Drone", drive: 1 })]), ); + const before = calculatorLoadRequest.token; await fireEvent.dblClick(ui.getByTestId("ship-classes-row")); - expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/ship-class/Drone"); + expect(calculatorLoadRequest.token).toBe(before + 1); + expect(calculatorLoadRequest.name).toBe("Drone"); }); test("delete button adds a removeShipClass to the draft", async () => { @@ -207,9 +210,11 @@ describe("ship-classes table", () => { expect(cmd.name).toBe("Drone"); }); - test("new button navigates to the empty designer", async () => { + test("new button requests a fresh calculator design", async () => { const ui = mountTable(makeReport([])); + const before = calculatorLoadRequest.token; await fireEvent.click(ui.getByTestId("ship-classes-new")); - expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/ship-class"); + expect(calculatorLoadRequest.token).toBe(before + 1); + expect(calculatorLoadRequest.name).toBeNull(); }); }); diff --git a/ui/frontend/tests/wasm-core-calc.test.ts b/ui/frontend/tests/wasm-core-calc.test.ts new file mode 100644 index 0000000..4d31d5d --- /dev/null +++ b/ui/frontend/tests/wasm-core-calc.test.ts @@ -0,0 +1,73 @@ +// Smoke test for the Phase 30 calculator bridge against the real +// (TinyGo-built) core.wasm. The calc-model and component suites use a +// fake Core; this file boots the actual WASM module to confirm every new +// function is registered in `ui/wasm/main.go` and marshals correctly — +// including the object return of `produceShipsInTurn` and the `null` +// infeasible result of the solvers. Requires `make wasm` to have run. + +import { beforeAll, describe, expect, test } from "vitest"; +import type { Core } from "../src/platform/core/index"; +import { loadWasmCoreForTest } from "./setup-wasm"; + +let core: Core; + +beforeAll(async () => { + core = await loadWasmCoreForTest(); +}); + +describe("WasmCore calculator bridge (Phase 30)", () => { + test("combat results", () => { + expect(core.effectiveAttack({ weapons: 15, weaponsTech: 1.5 })).toBeCloseTo( + 22.5, + 9, + ); + expect( + core.effectiveDefence({ shields: 20, shieldsTech: 1, fullMass: 45 }), + ).toBeCloseTo((20 / Math.cbrt(45)) * Math.cbrt(30), 6); + expect( + core.bombingPower({ weapons: 30, weaponsTech: 1, armament: 3, number: 1 }), + ).toBeCloseTo(139.29503, 3); + }); + + test("planet build", () => { + expect( + core.shipBuildCost({ shipMass: 10, material: 3, resources: 0.5 }), + ).toBeCloseTo(114, 9); + const r = core.produceShipsInTurn({ + productionAvailable: 100, + material: 100, + resources: 10, + shipMass: 1, + }); + expect(r).toEqual({ + ships: 10, + materialLeft: 90, + productionUsed: 0, + progress: 0, + }); + }); + + test("goal-seek solvers, including infeasible", () => { + expect( + core.weaponsForAttack({ targetAttack: 30, weaponsTech: 1.5 }), + ).toBeCloseTo(20, 9); + expect( + core.cargoForEmptyMass({ targetEmptyMass: 42, restMass: 30 }), + ).toBeCloseTo(12, 9); + expect( + core.loadForFullMass({ targetFullMass: 65, emptyMass: 45, cargoTech: 1 }), + ).toBeCloseTo(20, 9); + const shields = core.shieldsForDefence({ + targetDefence: 5, + shieldsTech: 1, + restMass: 40, + }); + expect(shields).not.toBeNull(); + expect(shields as number).toBeGreaterThan(0); + // Speed at/above the stripped-hull ceiling (20 × driveTech) is + // unreachable: the bridge returns null. + expect( + core.driveForSpeed({ targetSpeed: 100, driveTech: 1.2, restMass: 35 }), + ).toBeNull(); + }); +}); diff --git a/ui/wasm/main.go b/ui/wasm/main.go index dfb13b2..bbecb63 100644 --- a/ui/wasm/main.go +++ b/ui/wasm/main.go @@ -27,6 +27,21 @@ // - carryingMass(fields) -> number // - blockUpgradeCost(fields) -> number (Phase 20: modernize cost preview) // +// Phase 30 adds the calculator bridge over the combat, planet-build, and +// inverse goal-seek math in `pkg/calc` (combat / build helpers, plus the +// single-target solvers): +// +// - effectiveAttack(fields) -> number +// - effectiveDefence(fields) -> number +// - bombingPower(fields) -> number +// - shipBuildCost(fields) -> number +// - produceShipsInTurn(fields) -> { ships, materialLeft, productionUsed, progress } +// - weaponsForAttack(fields) -> number | null (null when infeasible) +// - driveForSpeed(fields) -> number | null +// - shieldsForDefence(fields) -> number | null +// - cargoForEmptyMass(fields) -> number | null +// - loadForFullMass(fields) -> number | null +// // Field objects are plain JS objects with camelCase keys matching the // TypeScript `Core` interface, and bytes fields are Uint8Array. // Timestamps are JS Number (Unix milliseconds fit in 53 bits well past @@ -49,18 +64,28 @@ import ( func main() { js.Global().Set("galaxyCore", js.ValueOf(map[string]any{ - "signRequest": js.FuncOf(signRequest), - "verifyResponse": js.FuncOf(verifyResponse), - "verifyEvent": js.FuncOf(verifyEvent), - "verifyPayloadHash": js.FuncOf(verifyPayloadHash), - "driveEffective": js.FuncOf(driveEffective), - "emptyMass": js.FuncOf(emptyMass), - "weaponsBlockMass": js.FuncOf(weaponsBlockMass), - "fullMass": js.FuncOf(fullMass), - "speed": js.FuncOf(speed), - "cargoCapacity": js.FuncOf(cargoCapacity), - "carryingMass": js.FuncOf(carryingMass), - "blockUpgradeCost": js.FuncOf(blockUpgradeCost), + "signRequest": js.FuncOf(signRequest), + "verifyResponse": js.FuncOf(verifyResponse), + "verifyEvent": js.FuncOf(verifyEvent), + "verifyPayloadHash": js.FuncOf(verifyPayloadHash), + "driveEffective": js.FuncOf(driveEffective), + "emptyMass": js.FuncOf(emptyMass), + "weaponsBlockMass": js.FuncOf(weaponsBlockMass), + "fullMass": js.FuncOf(fullMass), + "speed": js.FuncOf(speed), + "cargoCapacity": js.FuncOf(cargoCapacity), + "carryingMass": js.FuncOf(carryingMass), + "blockUpgradeCost": js.FuncOf(blockUpgradeCost), + "effectiveAttack": js.FuncOf(effectiveAttack), + "effectiveDefence": js.FuncOf(effectiveDefence), + "bombingPower": js.FuncOf(bombingPower), + "shipBuildCost": js.FuncOf(shipBuildCost), + "produceShipsInTurn": js.FuncOf(produceShipsInTurn), + "weaponsForAttack": js.FuncOf(weaponsForAttack), + "driveForSpeed": js.FuncOf(driveForSpeed), + "shieldsForDefence": js.FuncOf(shieldsForDefence), + "cargoForEmptyMass": js.FuncOf(cargoForEmptyMass), + "loadForFullMass": js.FuncOf(loadForFullMass), })) // Block forever so the Go runtime stays alive while JS keeps calling @@ -241,6 +266,160 @@ func blockUpgradeCost(_ js.Value, args []js.Value) any { return js.ValueOf(calc.BlockUpgradeCost(blockMass, currentTech, targetTech)) } +// effectiveAttack bridges `calc.EffectiveAttack`. Input +// `{ weapons, weaponsTech }`, output a JS number. +func effectiveAttack(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + weapons := args[0].Get("weapons").Float() + weaponsTech := args[0].Get("weaponsTech").Float() + return js.ValueOf(calc.EffectiveAttack(weapons, weaponsTech)) +} + +// effectiveDefence bridges `calc.EffectiveDefence`. Input +// `{ shields, shieldsTech, fullMass }`, output a JS number (zero when +// fullMass is non-positive). +func effectiveDefence(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + shields := args[0].Get("shields").Float() + shieldsTech := args[0].Get("shieldsTech").Float() + fullMass := args[0].Get("fullMass").Float() + return js.ValueOf(calc.EffectiveDefence(shields, shieldsTech, fullMass)) +} + +// bombingPower bridges `calc.BombingPower`. Input +// `{ weapons, weaponsTech, armament, number }`, output a JS number. +func bombingPower(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + weapons := args[0].Get("weapons").Float() + weaponsTech := args[0].Get("weaponsTech").Float() + armament := args[0].Get("armament").Float() + number := args[0].Get("number").Float() + return js.ValueOf(calc.BombingPower(weapons, weaponsTech, armament, number)) +} + +// shipBuildCost bridges `calc.ShipBuildCost`. Input +// `{ shipMass, material, resources }`, output a JS number. +func shipBuildCost(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + shipMass := args[0].Get("shipMass").Float() + material := args[0].Get("material").Float() + resources := args[0].Get("resources").Float() + return js.ValueOf(calc.ShipBuildCost(shipMass, material, resources)) +} + +// produceShipsInTurn bridges `calc.ProduceShipsInTurn`. Input +// `{ productionAvailable, material, resources, shipMass }`, output a JS +// object `{ ships, materialLeft, productionUsed, progress }`. +func produceShipsInTurn(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + productionAvailable := args[0].Get("productionAvailable").Float() + material := args[0].Get("material").Float() + resources := args[0].Get("resources").Float() + shipMass := args[0].Get("shipMass").Float() + ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn( + productionAvailable, material, resources, shipMass, + ) + return js.ValueOf(map[string]any{ + "ships": float64(ships), + "materialLeft": materialLeft, + "productionUsed": productionUsed, + "progress": progress, + }) +} + +// weaponsForAttack bridges `calc.WeaponsForAttack`. Input +// `{ targetAttack, weaponsTech }`, output a JS number or null when the +// request is infeasible. +func weaponsForAttack(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + targetAttack := args[0].Get("targetAttack").Float() + weaponsTech := args[0].Get("weaponsTech").Float() + v, ok := calc.WeaponsForAttack(targetAttack, weaponsTech) + if !ok { + return js.Null() + } + return js.ValueOf(v) +} + +// driveForSpeed bridges `calc.DriveForSpeed`. Input +// `{ targetSpeed, driveTech, restMass }`, output a JS number or null when +// the target is unreachable. +func driveForSpeed(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + targetSpeed := args[0].Get("targetSpeed").Float() + driveTech := args[0].Get("driveTech").Float() + restMass := args[0].Get("restMass").Float() + v, ok := calc.DriveForSpeed(targetSpeed, driveTech, restMass) + if !ok { + return js.Null() + } + return js.ValueOf(v) +} + +// shieldsForDefence bridges `calc.ShieldsForDefence`. Input +// `{ targetDefence, shieldsTech, restMass }`, output a JS number or null +// when the request is infeasible. +func shieldsForDefence(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + targetDefence := args[0].Get("targetDefence").Float() + shieldsTech := args[0].Get("shieldsTech").Float() + restMass := args[0].Get("restMass").Float() + v, ok := calc.ShieldsForDefence(targetDefence, shieldsTech, restMass) + if !ok { + return js.Null() + } + return js.ValueOf(v) +} + +// cargoForEmptyMass bridges `calc.CargoForEmptyMass`. Input +// `{ targetEmptyMass, restMass }`, output a JS number or null when the +// target is below the fixed block mass. +func cargoForEmptyMass(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + targetEmptyMass := args[0].Get("targetEmptyMass").Float() + restMass := args[0].Get("restMass").Float() + v, ok := calc.CargoForEmptyMass(targetEmptyMass, restMass) + if !ok { + return js.Null() + } + return js.ValueOf(v) +} + +// loadForFullMass bridges `calc.LoadForFullMass`. Input +// `{ targetFullMass, emptyMass, cargoTech }`, output a JS number or null +// when the target is below the empty mass. +func loadForFullMass(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Null() + } + targetFullMass := args[0].Get("targetFullMass").Float() + emptyMass := args[0].Get("emptyMass").Float() + cargoTech := args[0].Get("cargoTech").Float() + v, ok := calc.LoadForFullMass(targetFullMass, emptyMass, cargoTech) + if !ok { + return js.Null() + } + return js.ValueOf(v) +} + // copyBytesFromJS materialises a JS Uint8Array (or any indexable // byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo` // because TinyGo's implementation panics on values it does not