Merge pull request 'Phase 30 — Ship Class Calculator (goal-seek, reach circles, planet build)' (#24) from feature/ui-calculator into development
Deploy · Dev / deploy (push) Successful in 38s
Tests · Go / test (push) Successful in 2m6s
Tests · Integration / integration (push) Successful in 1m43s
Tests · UI / test (push) Successful in 2m1s

This commit was merged in pull request #24.
This commit is contained in:
2026-05-21 19:47:16 +00:00
57 changed files with 4241 additions and 1301 deletions
+9 -19
View File
@@ -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
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
fval := game.F(pa)
p.Production.ProdUsed = &fval
used := game.F(productionUsed)
p.Production.ProdUsed = &used
return ships
} else {
pa -= totalCost
p.Mat(float64(p.Material) - shipMass + MATneed)
ships += 1
}
}
}
+6 -6
View File
@@ -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 {
+19
View File
@@ -0,0 +1,19 @@
package calc
import "math"
// Ceil3 rounds num UP to three decimal places. The ship-class
// calculator displays every computed value (and every goal-seek
// back-solved input) through this so a result is never shown lower than
// it really is — e.g. a speed of 5.0003 reads as 5.001, not 5.000, which
// matters when a fraction of a light-year decides whether a ship clears
// the gap to a planet. It is display-only and lives here (rather than in
// the engine's round-to-nearest util.Fixed*) so the UI bridge can reach
// the one implementation through WASM.
//
// num is pre-rounded to nine decimals before the ceil so float64
// representation noise does not push an exact value up a step (e.g. a
// computed 5.0 stored as 5.0000000002 stays 5.0).
func Ceil3(num float64) float64 {
return math.Ceil(math.Round(num*1e9)/1e6) / 1000
}
+41 -4
View File
@@ -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++
}
}
+49
View File
@@ -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)
}
})
}
}
+11
View File
@@ -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
}
+24
View File
@@ -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)
}
})
}
}
+86
View File
@@ -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
}
+66
View File
@@ -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")
}
}
+120 -57
View File
@@ -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,93 @@ 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.
Status: done (gitea `go-unit` + `ui-test` green; deployed to dev). UI
polish deferred to a later pass; the core functionality is in place.
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 13 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 +3544,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
+10
View File
@@ -0,0 +1,10 @@
package calc
import "galaxy/calc"
// Ceil3 wraps `calc.Ceil3` (`pkg/calc/number.go`): round up to three
// decimal places. The calculator formats every displayed number through
// this bridge so the UI and the canonical Go implementation agree.
func Ceil3(num float64) float64 {
return calc.Ceil3(num)
}
+27
View File
@@ -0,0 +1,27 @@
package calc_test
import (
"testing"
source "galaxy/calc"
bridge "galaxy/core/calc"
"github.com/stretchr/testify/assert"
)
func TestCeil3Parity(t *testing.T) {
t.Parallel()
cases := []float64{0, 5, 5.0003, 4.2761, 139.29503, 0.0001, 1.9999999998}
for _, c := range cases {
assert.Equal(t, source.Ceil3(c), bridge.Ceil3(c))
}
}
func TestCeil3Values(t *testing.T) {
t.Parallel()
assert.Equal(t, 5.0, source.Ceil3(5.0))
assert.Equal(t, 5.001, source.Ceil3(5.0003))
assert.Equal(t, 4.277, source.Ceil3(4.2761))
// Float noise just above an exact step stays put.
assert.Equal(t, 5.0, source.Ceil3(5.0000000002))
}
+22
View File
@@ -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)
}
+57
View File
@@ -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)
})
}
}
+23
View File
@@ -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)
}
+63
View File
@@ -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)
})
}
}
+42
View File
@@ -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)
}
+75
View File
@@ -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)
}
}
+33 -7
View File
@@ -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,28 @@ 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)|
| `ceil3` | `calc.Ceil3(value)` (`pkg/calc/number.go`) | `number` | calculator display rounding (round up to 3 dp) |
`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 +109,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`/
+135
View File
@@ -0,0 +1,135 @@
# 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 (in cargo
units) that the loaded-column results use. At **full** the toggle
shows the ship's cargo capacity; a **custom** load over that capacity
is flagged as an error. With a zero cargo block there is no hold, so
the load is pinned to empty and the toggle is disabled.
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. Locking a speed is disabled when the drive block is
zero (a deliberately immobile ship has no speed to back-solve).
## Validation and display
Every numeric input is validated independently and an offending one gets
a red border and a hover/tap tooltip with the reason: no value may be
negative, the five blocks follow the engine value rules
(`pkg/calc/validator.go`, surfaced per-field by
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity.
Every displayed number — the derived results and the goal-seek
back-solved input — is rounded **up** to three decimals through the
shared `pkg/calc/number.go.Ceil3` (bridged as `core.ceil3`), so a value
is never shown lower than it is (a speed of 5.0003 reads 5.001). The
engine keeps its own round-to-nearest `util.Fixed*`; `Ceil3` is a
display-only helper that lives in `pkg/calc` so the UI and Go share one
implementation.
## 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 13 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.
+13 -11
View File
@@ -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
@@ -1,573 +0,0 @@
<!--
Phase 17 ship-class designer. Two modes driven by the optional
`classId` URL segment:
- **new (no classId)** — empty form with five numeric fields
plus name. Save is disabled until `validateShipClass` returns
`ok`; the localised tooltip mirrors `validateEntityName`'s
invalid-reason messages and the value-rule mirrors of
`pkg/calc/validator.go.ValidateShipTypeValues`. Save adds a
`createShipClass` to the local order draft and returns to the
table.
- **view (classId set)** — read-only render of the matching row
from the optimistic overlay. Ship classes are designed once
and cannot be modified after creation (per
`game/rules.txt`); the in-game upgrade story lives elsewhere
(`CommandShipGroupUpgrade`, Phase 19/20). The view exposes a
Delete affordance (engine-side checks ensure the class is not
referenced by active production / ship groups) and a Back
button.
Phase 18 wires `pkg/calc/` (via the `Core` WASM bridge) into the
new-mode form: an `<aside class="preview">` block recomputes mass,
full-load mass, max speed, range at full load, and cargo capacity
on every form change, using the local player's tech levels off the
rendered report. The preview hides itself until the form passes
validation, so it never displays half-cooked numbers.
-->
<script lang="ts">
import { getContext, tick } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { ShipClassSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import {
validateShipClass,
type ShipClassInvalidReason,
} from "$lib/util/ship-class-validation";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const gameId = $derived(page.params.id ?? "");
const classId = $derived(page.params.classId ?? "");
const isViewMode = $derived(classId !== "");
const localShipClass = $derived<ShipClassSummary[]>(
rendered?.report?.localShipClass ?? [],
);
const existingNames = $derived(localShipClass.map((cls) => cls.name));
const viewing = $derived(
isViewMode
? localShipClass.find((cls) => cls.name === classId) ?? null
: null,
);
let name = $state("");
let drive = $state(0);
let armament = $state(0);
let weapons = $state(0);
let shields = $state(0);
let cargo = $state(0);
let nameInputEl: HTMLInputElement | null = $state(null);
const invalidReasonKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
empty: "game.designer.ship_class.invalid.empty",
too_long: "game.designer.ship_class.invalid.too_long",
starts_with_special: "game.designer.ship_class.invalid.starts_with_special",
ends_with_special: "game.designer.ship_class.invalid.ends_with_special",
consecutive_specials:
"game.designer.ship_class.invalid.consecutive_specials",
whitespace: "game.designer.ship_class.invalid.whitespace",
disallowed_character:
"game.designer.ship_class.invalid.disallowed_character",
duplicate_name: "game.designer.ship_class.invalid.duplicate_name",
drive_value: "game.designer.ship_class.invalid.drive_value",
armament_value: "game.designer.ship_class.invalid.armament_value",
armament_not_integer:
"game.designer.ship_class.invalid.armament_not_integer",
weapons_value: "game.designer.ship_class.invalid.weapons_value",
shields_value: "game.designer.ship_class.invalid.shields_value",
cargo_value: "game.designer.ship_class.invalid.cargo_value",
armament_weapons_pair:
"game.designer.ship_class.invalid.armament_weapons_pair",
all_zero: "game.designer.ship_class.invalid.all_zero",
};
const validation = $derived(
validateShipClass(
{ name, drive, armament, weapons, shields, cargo },
{ existingNames },
),
);
const invalidMessage = $derived(
validation.ok ? "" : i18n.t(invalidReasonKeyMap[validation.reason]),
);
const canSave = $derived(validation.ok && draft !== undefined);
const driveTech = $derived(rendered?.report?.localPlayerDrive ?? 0);
const cargoTech = $derived(rendered?.report?.localPlayerCargo ?? 0);
interface PreviewValues {
mass: number;
fullLoadMass: number;
maxSpeed: number;
rangeAtFull: number;
cargoCapacity: number;
}
const preview = $derived.by<PreviewValues | null>(() => {
const core = coreHandle?.core;
if (core === undefined || core === null) return null;
if (!validation.ok) return null;
const v = validation.value;
const mass = core.emptyMass({
drive: v.drive,
weapons: v.weapons,
armament: v.armament,
shields: v.shields,
cargo: v.cargo,
});
if (mass === null) return null;
const cargoCapacity = core.cargoCapacity({
cargo: v.cargo,
cargoTech,
});
const carryAtFull =
cargoTech > 0
? core.carryingMass({ load: cargoCapacity, cargoTech })
: 0;
const fullLoadMass = core.fullMass({
emptyMass: mass,
carryingMass: carryAtFull,
});
const driveEffective = core.driveEffective({
drive: v.drive,
driveTech,
});
const maxSpeed = core.speed({ driveEffective, fullMass: mass });
const rangeAtFull = core.speed({
driveEffective,
fullMass: fullLoadMass,
});
return { mass, fullLoadMass, maxSpeed, rangeAtFull, cargoCapacity };
});
$effect(() => {
if (!isViewMode) {
void tick().then(() => nameInputEl?.focus());
}
});
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function backToTable(): void {
void goto(`/games/${gameId}/table/ship-classes`);
}
async function save(): Promise<void> {
if (!validation.ok || draft === undefined) return;
await draft.add({
kind: "createShipClass",
id: crypto.randomUUID(),
name: validation.value.name,
drive: validation.value.drive,
armament: validation.value.armament,
weapons: validation.value.weapons,
shields: validation.value.shields,
cargo: validation.value.cargo,
});
backToTable();
}
async function deleteThis(): Promise<void> {
if (viewing === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: viewing.name,
});
backToTable();
}
</script>
<section
class="active-view"
data-testid="active-view-designer-ship-class"
data-mode={isViewMode ? "view" : "new"}
>
{#if isViewMode}
{#if viewing === null}
<h2>{i18n.t("game.view.designer.ship_class")}</h2>
<p class="not-found" data-testid="designer-ship-class-not-found">
{i18n.t("game.designer.ship_class.not_found", { name: classId })}
</p>
<div class="actions">
<button
type="button"
data-testid="designer-ship-class-back"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.back")}
</button>
</div>
{:else}
<h2 data-testid="designer-ship-class-title">
{i18n.t("game.designer.ship_class.title.view", { name: viewing.name })}
</h2>
<p class="notice" data-testid="designer-ship-class-notice">
{i18n.t("game.designer.ship_class.read_only_notice")}
</p>
<dl class="fields">
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.name")}</dt>
<dd data-testid="designer-ship-class-view-name">{viewing.name}</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.drive")}</dt>
<dd data-testid="designer-ship-class-view-drive">
{formatNumber(viewing.drive)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.armament")}</dt>
<dd data-testid="designer-ship-class-view-armament">
{viewing.armament}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.weapons")}</dt>
<dd data-testid="designer-ship-class-view-weapons">
{formatNumber(viewing.weapons)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.shields")}</dt>
<dd data-testid="designer-ship-class-view-shields">
{formatNumber(viewing.shields)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.cargo")}</dt>
<dd data-testid="designer-ship-class-view-cargo">
{formatNumber(viewing.cargo)}
</dd>
</div>
</dl>
<div class="actions">
<button
type="button"
class="back"
data-testid="designer-ship-class-back"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.back")}
</button>
<button
type="button"
class="delete"
data-testid="designer-ship-class-delete"
disabled={draft === undefined}
onclick={() => void deleteThis()}
>
{i18n.t("game.designer.ship_class.action.delete")}
</button>
</div>
{/if}
{:else}
<h2 data-testid="designer-ship-class-title">
{i18n.t("game.designer.ship_class.title.new")}
</h2>
<p class="hint" data-testid="designer-ship-class-hint">
{i18n.t("game.designer.ship_class.hint.values")}
</p>
<form
class="form"
data-testid="designer-ship-class-form"
onsubmit={(event) => {
event.preventDefault();
void save();
}}
>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.name")}</span>
<input
type="text"
bind:this={nameInputEl}
bind:value={name}
data-testid="designer-ship-class-input-name"
maxlength="30"
aria-invalid={validation.ok ? "false" : "true"}
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.drive")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={drive}
data-testid="designer-ship-class-input-drive"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.armament")}</span>
<input
type="number"
step="1"
min="0"
bind:value={armament}
data-testid="designer-ship-class-input-armament"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.weapons")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={weapons}
data-testid="designer-ship-class-input-weapons"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.shields")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={shields}
data-testid="designer-ship-class-input-shields"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.cargo")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={cargo}
data-testid="designer-ship-class-input-cargo"
/>
</label>
{#if !validation.ok}
<p class="error" data-testid="designer-ship-class-error">
{invalidMessage}
</p>
{/if}
{#if preview !== null}
<aside
class="preview"
data-testid="designer-ship-class-preview"
>
<h3>{i18n.t("game.designer.ship_class.preview.title")}</h3>
<dl>
<div class="row">
<dt>{i18n.t("game.designer.ship_class.preview.mass")}</dt>
<dd data-testid="designer-ship-class-preview-mass">
{formatNumber(preview.mass)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.full_load_mass")}
</dt>
<dd data-testid="designer-ship-class-preview-full-load-mass">
{formatNumber(preview.fullLoadMass)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.max_speed")}
</dt>
<dd data-testid="designer-ship-class-preview-max-speed">
{formatNumber(preview.maxSpeed)}
</dd>
</div>
<div class="row">
<dt>{i18n.t("game.designer.ship_class.preview.range")}</dt>
<dd data-testid="designer-ship-class-preview-range">
{formatNumber(preview.rangeAtFull)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.cargo_capacity")}
</dt>
<dd data-testid="designer-ship-class-preview-cargo-capacity">
{formatNumber(preview.cargoCapacity)}
</dd>
</div>
</dl>
</aside>
{/if}
<div class="actions">
<button
type="button"
class="cancel"
data-testid="designer-ship-class-cancel"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.cancel")}
</button>
<button
type="submit"
class="save"
data-testid="designer-ship-class-save"
disabled={!canSave}
title={canSave ? "" : invalidMessage}
>
{i18n.t("game.designer.ship_class.action.save")}
</button>
</div>
</form>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.active-view h2 {
margin: 0;
font-size: 1.1rem;
}
.notice,
.hint,
.not-found {
margin: 0;
color: #888;
font-size: 0.85rem;
}
.form {
display: flex;
flex-direction: column;
gap: 0.55rem;
max-width: 30rem;
}
.row {
display: grid;
grid-template-columns: 8rem 1fr;
align-items: center;
gap: 0.6rem;
}
.row span {
color: #aab;
font-size: 0.85rem;
}
.row input {
font: inherit;
padding: 0.3rem 0.5rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.row input[aria-invalid="true"] {
border-color: #d97a7a;
}
.error {
margin: 0;
font-size: 0.8rem;
color: #d97a7a;
}
.preview {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem 0.85rem;
background: #0a0e1a;
border: 1px solid #2a3150;
border-radius: 4px;
max-width: 30rem;
}
.preview h3 {
margin: 0;
font-size: 0.85rem;
color: #aab;
font-weight: 500;
}
.preview dl {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.2rem;
column-gap: 0.75rem;
}
.preview .row {
display: contents;
}
.preview dt {
color: #aab;
font-size: 0.85rem;
}
.preview dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
text-align: right;
}
.fields {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.75rem;
max-width: 30rem;
}
.field {
display: contents;
}
.field dt {
color: #aab;
font-size: 0.85rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.actions button {
font: inherit;
font-size: 0.9rem;
padding: 0.3rem 0.7rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.actions button:not(:disabled):hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.actions .delete {
color: #d97a7a;
}
.actions .delete:not(:disabled):hover {
border-color: #d97a7a;
color: #f0a0a0;
}
</style>
+31 -1
View File
@@ -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<Record<string, string>>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
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;
@@ -14,8 +14,6 @@ data fetching is performed here — the layout is responsible.
-->
<script lang="ts">
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { ShipClassSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -27,6 +25,7 @@ data fetching is performed here — the layout is responsible.
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
type SortColumn =
| "name"
@@ -62,7 +61,6 @@ data fetching is performed here — the layout is responsible.
ORDER_DRAFT_CONTEXT_KEY,
);
const gameId = $derived(page.params.id ?? "");
let sortColumn: SortColumn = $state("name");
let sortDirection: SortDirection = $state("asc");
@@ -111,14 +109,12 @@ data fetching is performed here — the layout is responsible.
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function openDesigner(name: string): void {
void goto(
`/games/${gameId}/designer/ship-class/${encodeURIComponent(name)}`,
);
function openInCalculator(name: string): void {
calculatorLoadRequest.request(name);
}
function newShipClass(): void {
void goto(`/games/${gameId}/designer/ship-class`);
calculatorLoadRequest.request(null);
}
async function deleteShipClass(name: string): Promise<void> {
@@ -194,7 +190,7 @@ data fetching is performed here — the layout is responsible.
<tr
data-testid="ship-classes-row"
data-name={cls.name}
ondblclick={() => openDesigner(cls.name)}
ondblclick={() => openInCalculator(cls.name)}
>
<td data-testid="ship-classes-cell-name">{cls.name}</td>
<td data-testid="ship-classes-cell-drive">{formatNumber(cls.drive)}</td>
@@ -0,0 +1,370 @@
// Pure orchestration for the ship-class calculator. The calculator
// renders three areas — ship design, derived results, planet build — and
// supports single-target "goal-seek": the player pins one derived result
// and the model back-solves the single input it claims. All numeric math
// lives in `pkg/calc` (reached through `Core`); this module only decides
// which `Core` call to make, in what order, and how to fold the result
// back into the field set. Keeping it a pure function of
// `(CalculatorInput, Core)` makes the goal-seek logic unit-testable
// without booting WASM or mounting a component.
import type { Core } from "../../platform/core/index";
import {
validateShipClassValues,
type ShipClassValueInvalidReason,
} from "../util/ship-class-validation";
/** LockableOutputId names every derived result the player may pin. */
export type LockableOutputId =
| "emptyMass"
| "loadedMass"
| "speedEmpty"
| "speedLoaded"
| "attack"
| "defense";
/** ClaimedInput names every input a locked result can back-solve. */
export type ClaimedInput = "drive" | "weapons" | "shields" | "cargo" | "load";
/**
* CLAIM_MAP fixes which single input each lockable result back-solves.
* The pairing is the natural lever for each result: attack rides on the
* weapons block, defence on shields, both speeds on the drive block,
* empty mass on the cargo block (the free filler), and loaded mass on the
* cargo load.
*/
export const CLAIM_MAP: Record<LockableOutputId, ClaimedInput> = {
emptyMass: "cargo",
loadedMass: "load",
speedEmpty: "drive",
speedLoaded: "drive",
attack: "weapons",
defense: "shields",
};
export type LoadMode = "empty" | "full" | "custom";
export interface DesignBlocks {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
export interface CalculatorInput {
blocks: DesignBlocks;
// Effective tech levels (the caller resolves default vs. override).
driveTech: number;
weaponsTech: number;
shieldsTech: number;
cargoTech: number;
loadMode: LoadMode;
customLoad: number;
// The single pinned result, or null when nothing is locked.
lock: { output: LockableOutputId; value: number } | null;
}
export interface CalculatorOutputs {
emptyMass: number;
loadedMass: number;
speedEmpty: number;
speedLoaded: number;
attack: number;
defense: number;
bombing: number;
}
export interface CalculatorResult {
/** Blocks after goal-seek may have overwritten the claimed one. */
blocks: DesignBlocks;
/** Which input the active lock drove, or null. */
computedInput: ClaimedInput | null;
/** False when the lock's target cannot be reached. */
lockFeasible: boolean;
/** Whether the resolved blocks pass the engine value rules. */
valuesValid: boolean;
valueReason: ShipClassValueInvalidReason | null;
/** Resolved cargo load in cargo units. */
load: number;
cargoCapacity: number;
/** Derived results, or null when invalid / no Core. */
outputs: CalculatorOutputs | null;
}
function resolveLoad(
mode: LoadMode,
customLoad: number,
cargo: number,
cargoTech: number,
core: Core,
): number {
if (mode === "empty") return 0;
if (mode === "custom") return customLoad > 0 ? customLoad : 0;
return core.cargoCapacity({ cargo, cargoTech });
}
// solveClaimedBlock back-solves the block claimed by a locked result
// (everything except a `load` claim, which is resolved with the cargo
// load). Returns null when the target is unreachable or the design's
// weapons/armament pairing is invalid.
function solveClaimedBlock(
lock: { output: LockableOutputId; value: number },
raw: DesignBlocks,
input: CalculatorInput,
prelimLoad: number,
core: Core,
): number | null {
switch (lock.output) {
case "attack":
return core.weaponsForAttack({
targetAttack: lock.value,
weaponsTech: input.weaponsTech,
});
case "defense": {
const restExclShields = core.emptyMass({ ...raw, shields: 0 });
if (restExclShields === null) return null;
const carrying = core.carryingMass({
load: prelimLoad,
cargoTech: input.cargoTech,
});
return core.shieldsForDefence({
targetDefence: lock.value,
shieldsTech: input.shieldsTech,
restMass: restExclShields + carrying,
});
}
case "speedEmpty": {
const restExclDrive = core.emptyMass({ ...raw, drive: 0 });
if (restExclDrive === null) return null;
return core.driveForSpeed({
targetSpeed: lock.value,
driveTech: input.driveTech,
restMass: restExclDrive,
});
}
case "speedLoaded": {
const restExclDrive = core.emptyMass({ ...raw, drive: 0 });
if (restExclDrive === null) return null;
const carrying = core.carryingMass({
load: prelimLoad,
cargoTech: input.cargoTech,
});
return core.driveForSpeed({
targetSpeed: lock.value,
driveTech: input.driveTech,
restMass: restExclDrive + carrying,
});
}
case "emptyMass": {
const restExclCargo = core.emptyMass({ ...raw, cargo: 0 });
if (restExclCargo === null) return null;
return core.cargoForEmptyMass({
targetEmptyMass: lock.value,
restMass: restExclCargo,
});
}
case "loadedMass":
// Claims the cargo load, resolved alongside the load below.
return null;
}
}
/**
* computeCalculator resolves the full calculator state for one input
* snapshot: it applies the active goal-seek lock (if any), resolves the
* cargo load, validates the blocks, and computes every derived result.
* `outputs` is null when no `Core` is available or the blocks are
* invalid, mirroring the Phase 18 designer rule of hiding the preview
* until the design is sound.
*/
export function computeCalculator(
input: CalculatorInput,
core: Core | null,
): CalculatorResult {
const raw = input.blocks;
if (core === null) {
return {
blocks: raw,
computedInput: null,
lockFeasible: true,
valuesValid: false,
valueReason: null,
load: 0,
cargoCapacity: 0,
outputs: null,
};
}
const blocks: DesignBlocks = { ...raw };
let computedInput: ClaimedInput | null = null;
let lockFeasible = true;
// Preliminary load from the raw cargo, used by solvers that need the
// carrying mass (speedLoaded, defence). It matches the final load for
// every claim except `emptyMass` (which solves cargo without load) and
// `loadedMass` (which solves the load itself).
const prelimLoad = resolveLoad(
input.loadMode,
input.customLoad,
raw.cargo,
input.cargoTech,
core,
);
if (input.lock !== null) {
const claimed = CLAIM_MAP[input.lock.output];
if (claimed !== "load") {
const solved = solveClaimedBlock(
input.lock,
raw,
input,
prelimLoad,
core,
);
if (solved === null) {
lockFeasible = false;
} else {
blocks[claimed] = solved;
computedInput = claimed;
}
}
}
let load: number;
if (input.lock !== null && CLAIM_MAP[input.lock.output] === "load") {
const emptyMass = core.emptyMass(blocks);
const solvedLoad =
emptyMass === null
? null
: core.loadForFullMass({
targetFullMass: input.lock.value,
emptyMass,
cargoTech: input.cargoTech,
});
if (solvedLoad === null) {
lockFeasible = false;
load = resolveLoad(
input.loadMode,
input.customLoad,
blocks.cargo,
input.cargoTech,
core,
);
} else {
load = solvedLoad;
computedInput = "load";
}
} else {
load = resolveLoad(
input.loadMode,
input.customLoad,
blocks.cargo,
input.cargoTech,
core,
);
}
const valuesValidation = validateShipClassValues(blocks);
const valuesValid = valuesValidation.ok;
const valueReason = valuesValidation.ok ? null : valuesValidation.reason;
const cargoCapacity = core.cargoCapacity({
cargo: blocks.cargo,
cargoTech: input.cargoTech,
});
let outputs: CalculatorOutputs | null = null;
if (valuesValid) {
const emptyMass = core.emptyMass(blocks);
if (emptyMass !== null) {
const carrying = core.carryingMass({ load, cargoTech: input.cargoTech });
const loadedMass = core.fullMass({ emptyMass, carryingMass: carrying });
const driveEffective = core.driveEffective({
drive: blocks.drive,
driveTech: input.driveTech,
});
outputs = {
emptyMass,
loadedMass,
speedEmpty: core.speed({ driveEffective, fullMass: emptyMass }),
speedLoaded: core.speed({ driveEffective, fullMass: loadedMass }),
attack: core.effectiveAttack({
weapons: blocks.weapons,
weaponsTech: input.weaponsTech,
}),
defense: core.effectiveDefence({
shields: blocks.shields,
shieldsTech: input.shieldsTech,
fullMass: loadedMass,
}),
bombing: core.bombingPower({
weapons: blocks.weapons,
weaponsTech: input.weaponsTech,
armament: blocks.armament,
number: 1,
}),
};
}
}
return {
blocks,
computedInput,
lockFeasible,
valuesValid,
valueReason,
load,
cargoCapacity,
outputs,
};
}
export interface PlanetBuildInput {
/** The designed ship's empty mass. */
shipMass: number;
/** Free industrial potential (the "L" parameter, FreeIndustry). */
freeIndustry: number;
/** Material stockpile (resolved: planet value or the player override). */
material: number;
/** Planet resources rating. */
resources: number;
}
export interface PlanetBuildResult {
/** Whole ships plus fractional progress completable this turn. */
shipsPerTurn: number;
wholeShips: number;
progress: number;
/** Turns to finish one ship, or null when none can be produced. */
turnsPerShip: number | null;
}
/**
* computePlanetBuild folds one turn of ship production into the headline
* "ships per turn" and "turns per ship" the planet area shows. It assumes
* the planet keeps building this ship at the current (or overridden) MAT;
* the realistic multi-turn forecast with population growth and CAP/COL
* supply lands in Phase 34. Returns null without a `Core`.
*/
export function computePlanetBuild(
input: PlanetBuildInput,
core: Core | null,
): PlanetBuildResult | null {
if (core === null) return null;
if (input.shipMass <= 0 || input.freeIndustry <= 0) {
return { shipsPerTurn: 0, wholeShips: 0, progress: 0, turnsPerShip: null };
}
const r = core.produceShipsInTurn({
productionAvailable: input.freeIndustry,
material: input.material,
resources: input.resources,
shipMass: input.shipMass,
});
const shipsPerTurn = r.ships + r.progress;
return {
shipsPerTurn,
wholeShips: r.ships,
progress: r.progress,
turnsPerShip: shipsPerTurn > 0 ? 1 / shipsPerTurn : null,
};
}
@@ -0,0 +1,121 @@
// Long-lived state for the ship-class calculator. The sidebar unmounts
// the calculator tab when another tab is active, so component-local state
// would be lost on every tab switch (the inspector auto-opens on a planet
// click, for instance). The calculator is a long-lived planning tool, so
// its inputs live here — a page-level singleton that survives tab
// unmount/remount — and the component renders this store rather than its
// own `$state`.
//
// `ensureGame` resets the design when the active game changes so a draft
// from a previous game does not leak across games. `reset` is for tests,
// which share the module instance across cases.
import type { LoadMode, LockableOutputId } from "./calc-model";
interface Blocks {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
interface Tech {
drive: number;
weapons: number;
shields: number;
cargo: number;
}
type Mode = "ship" | "modernization";
function freshBlocks(): Blocks {
return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
}
function freshTech(): Tech {
return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
}
class CalculatorState {
gameId = $state<string | null>(null);
mode = $state<Mode>("ship");
name = $state("");
blocks = $state<Blocks>(freshBlocks());
techValues = $state<Tech>(freshTech());
techOverridden = $state<Record<keyof Tech, boolean>>({
drive: false,
weapons: false,
shields: false,
cargo: false,
});
targetTech = $state<Tech>(freshTech());
targetSeeded = $state(false);
loadMode = $state<LoadMode>("full");
customLoad = $state(0);
lock = $state<LockableOutputId | null>(null);
lockValue = $state(0);
matOverridden = $state(false);
matValue = $state(0);
loadedExisting = $state<string | null>(null);
// The last calculatorLoadRequest token this state has applied. Held
// here (not in the component) so a tab-switch remount does not
// re-apply the previous load request and clobber the kept design.
handledLoadToken = $state(0);
/** Clears the design back to a blank new-class form. */
resetDesign(): void {
this.blocks = freshBlocks();
this.name = "";
this.loadedExisting = null;
this.lock = null;
}
/** Full reset to defaults; used by tests sharing the singleton. */
reset(): void {
this.gameId = null;
this.mode = "ship";
this.resetDesign();
this.techValues = freshTech();
this.techOverridden = {
drive: false,
weapons: false,
shields: false,
cargo: false,
};
this.targetTech = freshTech();
this.targetSeeded = false;
this.loadMode = "full";
this.customLoad = 0;
this.lockValue = 0;
this.matOverridden = false;
this.matValue = 0;
this.handledLoadToken = 0;
}
/**
* Resets the per-game design when the active game changes, so a draft
* from one game does not surface in another. A no-op while the game is
* unchanged, which is what makes the design survive tab switches.
* `handledLoadToken` is intentionally preserved across games.
*/
ensureGame(gameId: string): void {
if (this.gameId === gameId) return;
this.gameId = gameId;
this.mode = "ship";
this.resetDesign();
this.techValues = freshTech();
this.techOverridden = {
drive: false,
weapons: false,
shields: false,
cargo: false,
};
this.targetTech = freshTech();
this.targetSeeded = false;
this.loadMode = "full";
this.customLoad = 0;
this.lockValue = 0;
this.matOverridden = false;
this.matValue = 0;
}
}
export const calculatorState = new CalculatorState();
@@ -0,0 +1,23 @@
// Shared signal that asks the sidebar calculator to open and load a ship
// class. The ship-classes table (row activation, "new" button) and the
// mobile bottom-tabs entry publish a request here; the in-game layout
// watches it to flip the sidebar to the calculator tab, and the
// calculator watches it to load the requested class. A module singleton
// keeps these siblings decoupled, mirroring `reach.svelte`.
//
// `token` increments on every request so a repeat request for the same
// class still re-triggers the watchers; each watcher records the last
// token it handled to act exactly once per request.
class CalculatorLoadRequest {
/** The class name to load, or null to start a fresh design. */
name: string | null = $state(null);
token = $state(0);
request(name: string | null): void {
this.name = name;
this.token += 1;
}
}
export const calculatorLoadRequest = new CalculatorLoadRequest();
@@ -0,0 +1,24 @@
// Shared bridge between the ship-class calculator (sidebar) and the map
// view: the calculator publishes the selected planet's origin and the
// current design's loaded speed here, and the map reads it to draw reach
// circles. A module singleton keeps the two siblings decoupled — neither
// imports the other — and survives sidebar tab switches. The store is
// cleared whenever the calculator has no valid design or no selected
// planet, which makes the map drop the rings.
class ReachStore {
origin: { x: number; y: number } | null = $state(null);
speedPerTurn = $state(0);
set(origin: { x: number; y: number }, speedPerTurn: number): void {
this.origin = origin;
this.speedPerTurn = speedPerTurn;
}
clear(): void {
this.origin = null;
this.speedPerTurn = 0;
}
}
export const reachStore = new ReachStore();
@@ -0,0 +1,218 @@
<!--
Reusable "Ship Class design area": the five design blocks (drive,
armament, weapons, shields, cargo) plus the four tech levels they are
built with. Each tech defaults to the player's current level and shows a
lock icon once overridden; clicking the lock resets it. A block claimed
by an active goal-seek lock renders read-only with its own lock marker.
The component is presentational — the parent owns the state and the
calculator math — so the ship-group upgrade flow can reuse it later.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
shipClassFieldErrors,
type ShipClassValueInvalidReason,
} from "$lib/util/ship-class-validation";
import type { ClaimedInput } from "./calc-model";
export interface DesignBlocksState {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
export interface TechState {
drive: number;
weapons: number;
shields: number;
cargo: number;
}
export type TechKey = keyof TechState;
type Props = {
blocks: DesignBlocksState;
// Blocks after goal-seek: the claimed block carries its solved
// value, which is what the read-only computed cell displays.
resolved: DesignBlocksState;
techs: TechState;
techOverridden: Record<TechKey, boolean>;
computedInput?: ClaimedInput | null;
blocksReadonly?: boolean;
onTechInput: (key: TechKey) => void;
onResetTech: (key: TechKey) => void;
};
let {
blocks = $bindable(),
resolved,
techs = $bindable(),
techOverridden,
computedInput = null,
blocksReadonly = false,
onTechInput,
onResetTech,
}: Props = $props();
const VALUE_REASON_KEY: Record<ShipClassValueInvalidReason, TranslationKey> = {
drive_value: "game.calculator.invalid.drive_value",
armament_value: "game.calculator.invalid.armament_value",
armament_not_integer: "game.calculator.invalid.armament_not_integer",
weapons_value: "game.calculator.invalid.weapons_value",
shields_value: "game.calculator.invalid.shields_value",
cargo_value: "game.calculator.invalid.cargo_value",
armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
all_zero: "game.calculator.invalid.all_zero",
};
// Per-block validity (independent of which one failed first) so every
// invalid input is highlighted, not only the first.
const blockErrors = $derived(shipClassFieldErrors(blocks));
function blockError(key: keyof DesignBlocksState): string {
const reason = blockErrors[key];
return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]);
}
function techError(key: TechKey): string {
return techs[key] < 0 ? i18n.t("game.calculator.invalid.tech_value") : "";
}
const BLOCK_ROWS: {
key: keyof DesignBlocksState;
label: () => string;
step: string;
tech: TechKey | null;
}[] = [
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
];
</script>
<div class="design" data-testid="calculator-design-area">
<div class="cols">
<span></span>
<span class="col-head">{i18n.t("game.calculator.col.ship")}</span>
<span class="col-head">{i18n.t("game.calculator.col.tech")}</span>
</div>
{#each BLOCK_ROWS as row (row.key)}
{@const isComputed = computedInput === row.key}
<div class="row">
<span class="label">{row.label()}</span>
{#if isComputed}
<input
class="ship"
type="number"
step={row.step}
readonly
value={resolved[row.key]}
data-computed="true"
data-testid={`calculator-block-${row.key}`}
title={i18n.t("game.calculator.lock.reset")}
/>
{:else}
<input
class="ship"
type="number"
step={row.step}
min="0"
bind:value={blocks[row.key]}
readonly={blocksReadonly}
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
title={blockError(row.key)}
data-testid={`calculator-block-${row.key}`}
/>
{/if}
{#if row.tech !== null}
{@const techKey = row.tech}
<span class="tech-cell">
<input
class="tech"
type="number"
step="0.001"
min="0"
bind:value={techs[techKey]}
oninput={() => onTechInput(techKey)}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
/>
{#if techOverridden[techKey]}
<button
type="button"
class="lock"
title={i18n.t("game.calculator.tech.reset")}
aria-label={i18n.t("game.calculator.tech.reset")}
data-testid={`calculator-tech-reset-${techKey}`}
onclick={() => onResetTech(techKey)}
>
🔒
</button>
{/if}
</span>
{:else}
<span></span>
{/if}
</div>
{/each}
</div>
<style>
.design {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.cols,
.row {
display: grid;
grid-template-columns: 4.5rem 1fr 1fr;
align-items: center;
gap: 0.35rem;
}
.col-head {
color: #8890b0;
font-size: 0.7rem;
text-align: center;
text-transform: lowercase;
}
.label {
color: #aab;
font-size: 0.8rem;
}
input {
font: inherit;
font-size: 0.8rem;
width: 100%;
min-width: 0;
padding: 0.2rem 0.35rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
font-variant-numeric: tabular-nums;
}
input[data-computed="true"],
input[readonly] {
color: #9fb0ff;
background: #11162a;
}
input[aria-invalid="true"] {
border-color: #d97a7a;
}
.tech-cell {
display: flex;
align-items: center;
gap: 0.2rem;
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
}
</style>
@@ -135,14 +135,6 @@ polishes microcopy.
</span>
{/if}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-designer-ship-class"
onclick={() => go(`/games/${gameId}/designer/ship-class`)}
>
{i18n.t("game.view.designer.ship_class")}
</button>
<button
type="button"
role="menuitem"
+63
View File
@@ -337,6 +337,69 @@ const en = {
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
"game.designer.ship_class.preview.unavailable": "—",
"game.calculator.title": "ship class calculator",
"game.calculator.mode.ship": "calculator",
"game.calculator.mode.modernization": "modernization",
"game.calculator.name.placeholder": "new class name",
"game.calculator.name.existing": "your ship classes",
"game.calculator.action.create": "create",
"game.calculator.action.delete": "delete",
"game.calculator.col.ship": "ship",
"game.calculator.col.tech": "tech",
"game.calculator.field.drive": "drive",
"game.calculator.field.armament": "armament",
"game.calculator.field.weapons": "weapons",
"game.calculator.field.shields": "shields",
"game.calculator.field.cargo": "cargo",
"game.calculator.load.label": "load",
"game.calculator.load.empty": "empty",
"game.calculator.load.full": "full",
"game.calculator.load.custom": "custom",
"game.calculator.col.empty": "empty",
"game.calculator.col.loaded": "loaded",
"game.calculator.out.mass": "mass",
"game.calculator.out.speed": "speed",
"game.calculator.out.attack": "attack",
"game.calculator.out.defense": "defense",
"game.calculator.out.bombing": "bombing",
"game.calculator.out.cargo_capacity": "cargo capacity",
"game.calculator.planet.title": "planet",
"game.calculator.planet.none": "select one of your planets on the map",
"game.calculator.planet.label": "planet {name} (#{number})",
"game.calculator.planet.mat": "MAT",
"game.calculator.planet.ships_per_turn": "ships / turn",
"game.calculator.planet.turns_per_ship": "turns / ship",
"game.calculator.lock.reset": "locked — click to release to the computed value",
"game.calculator.lock.infeasible": "this target cannot be reached with the current design",
"game.calculator.lock.max": "release the locked result first — one result at a time",
"game.calculator.tech.reset": "overridden — click to reset to your current tech",
"game.calculator.mat.reset": "overridden — click to reset to the planet value",
"game.calculator.modern.current": "current",
"game.calculator.modern.target": "target",
"game.calculator.modern.cost": "upgrade cost",
"game.calculator.modern.total": "total upgrade cost",
"game.calculator.unavailable": "—",
"game.calculator.invalid.empty": "name cannot be empty",
"game.calculator.invalid.too_long": "name is too long (30 characters max)",
"game.calculator.invalid.starts_with_special": "name cannot start with a special character",
"game.calculator.invalid.ends_with_special": "name cannot end with a special character",
"game.calculator.invalid.consecutive_specials": "too many special characters in a row",
"game.calculator.invalid.whitespace": "name cannot contain spaces",
"game.calculator.invalid.disallowed_character": "name contains disallowed characters",
"game.calculator.invalid.duplicate_name": "a ship class with this name already exists",
"game.calculator.invalid.drive_value": "drive must be 0 or ≥ 1",
"game.calculator.invalid.armament_value": "armament must be 0 or a positive integer",
"game.calculator.invalid.armament_not_integer": "armament must be an integer",
"game.calculator.invalid.weapons_value": "weapons must be 0 or ≥ 1",
"game.calculator.invalid.shields_value": "shields must be 0 or ≥ 1",
"game.calculator.invalid.cargo_value": "cargo must be 0 or ≥ 1",
"game.calculator.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
"game.calculator.invalid.all_zero": "at least one value must be nonzero",
"game.calculator.invalid.negative": "value cannot be negative",
"game.calculator.invalid.tech_value": "tech level cannot be negative",
"game.calculator.invalid.load_over_capacity": "load exceeds the ship's cargo capacity",
"game.calculator.lock.no_drive": "set a non-zero drive before locking speed",
"game.table.sciences.title": "sciences",
"game.table.sciences.column.name": "name",
"game.table.sciences.column.drive": "drive %",
+63
View File
@@ -338,6 +338,69 @@ const ru: Record<keyof typeof en, string> = {
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
"game.designer.ship_class.preview.unavailable": "—",
"game.calculator.title": "калькулятор классов кораблей",
"game.calculator.mode.ship": "калькулятор",
"game.calculator.mode.modernization": "модернизация",
"game.calculator.name.placeholder": "имя нового класса",
"game.calculator.name.existing": "ваши классы кораблей",
"game.calculator.action.create": "создать",
"game.calculator.action.delete": "удалить",
"game.calculator.col.ship": "корабль",
"game.calculator.col.tech": "технологии",
"game.calculator.field.drive": "двигатель",
"game.calculator.field.armament": "вооружённость",
"game.calculator.field.weapons": "оружие",
"game.calculator.field.shields": "защита",
"game.calculator.field.cargo": "трюм",
"game.calculator.load.label": "загрузка",
"game.calculator.load.empty": "пусто",
"game.calculator.load.full": "полная",
"game.calculator.load.custom": "своя",
"game.calculator.col.empty": "пустой",
"game.calculator.col.loaded": "гружёный",
"game.calculator.out.mass": "масса",
"game.calculator.out.speed": "скорость",
"game.calculator.out.attack": "атака",
"game.calculator.out.defense": "защита",
"game.calculator.out.bombing": "бомбардировка",
"game.calculator.out.cargo_capacity": "грузоподъёмность",
"game.calculator.planet.title": "планета",
"game.calculator.planet.none": "выберите свою планету на карте",
"game.calculator.planet.label": "планета {name} (#{number})",
"game.calculator.planet.mat": "MAT",
"game.calculator.planet.ships_per_turn": "кораблей / ход",
"game.calculator.planet.turns_per_ship": "ходов / корабль",
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
"game.calculator.modern.current": "текущий",
"game.calculator.modern.target": "целевой",
"game.calculator.modern.cost": "стоимость апгрейда",
"game.calculator.modern.total": "суммарная стоимость апгрейда",
"game.calculator.unavailable": "—",
"game.calculator.invalid.empty": "имя не может быть пустым",
"game.calculator.invalid.too_long": "имя слишком длинное (максимум 30 символов)",
"game.calculator.invalid.starts_with_special": "имя не может начинаться со спецсимвола",
"game.calculator.invalid.ends_with_special": "имя не может заканчиваться спецсимволом",
"game.calculator.invalid.consecutive_specials": "слишком много спецсимволов подряд",
"game.calculator.invalid.whitespace": "имя не может содержать пробелы",
"game.calculator.invalid.disallowed_character": "имя содержит недопустимые символы",
"game.calculator.invalid.duplicate_name": "класс корабля с таким именем уже существует",
"game.calculator.invalid.drive_value": "двигатель должен быть 0 или ≥ 1",
"game.calculator.invalid.armament_value": "вооружённость должна быть 0 или положительным целым",
"game.calculator.invalid.armament_not_integer": "вооружённость должна быть целым числом",
"game.calculator.invalid.weapons_value": "оружие должно быть 0 или ≥ 1",
"game.calculator.invalid.shields_value": "защита должна быть 0 или ≥ 1",
"game.calculator.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
"game.calculator.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
"game.calculator.invalid.negative": "значение не может быть отрицательным",
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
"game.table.sciences.title": "науки",
"game.table.sciences.column.name": "название",
"game.table.sciences.column.drive": "двигатель %",
@@ -179,14 +179,6 @@ destinations beats the duplication.
>
{i18n.t("game.view.mail")}
</button>
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-designer-ship-class"
onclick={() => go(`/games/${gameId}/designer/ship-class`)}
>
{i18n.t("game.view.designer.ship_class")}
</button>
<button
type="button"
role="menuitem"
+887 -14
View File
@@ -1,29 +1,902 @@
<!--
Phase 10 stub for the Calculator sidebar tool. Phase 30 wires the
real ship/path calculator. Until then the stub renders a localised
`coming soon` paragraph with a stable testid that later phases can
replace without touching navigation.
Phase 30 ship-class calculator. Replaces the Phase 17/18 standalone
designer: it fuses the ship design blocks, the live derived results
(mass, speed, attack, defence, bombing), and a planet build-rate readout
into one sidebar tool, and adds single-target goal-seek — the player pins
one result and the model back-solves the single input it claims (see
`lib/calculator/calc-model.ts`). A second mode reuses the design area to
price ship-class modernization. All math comes from `pkg/calc` through
the `Core` WASM bridge; this component only renders + orchestrates.
Input state lives in the long-lived `calculatorState` singleton, not in
the component, so it survives the sidebar unmounting this tab on a tab
switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import { getContext } from "svelte";
import { page } from "$app/state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import type { ReportPlanet, ShipClassSummary } from "../../api/game-state";
import {
validateShipClass,
type ShipClassInvalidReason,
} from "$lib/util/ship-class-validation";
import {
computeCalculator,
computePlanetBuild,
type LockableOutputId,
type LoadMode,
} from "$lib/calculator/calc-model";
import ShipDesignArea, {
type TechKey,
} from "$lib/calculator/ship-design-area.svelte";
import { reachStore } from "$lib/calculator/reach.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
import { calculatorState } from "$lib/calculator/calc-state.svelte";
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
// The long-lived input state (survives tab unmount/remount).
const cs = calculatorState;
// Reset the design when the active game changes; a no-op otherwise, so
// the design persists across tab switches within a game.
$effect(() => {
cs.ensureGame(page.params.id ?? "");
});
const core = $derived(coreHandle?.core ?? null);
const report = $derived(rendered?.report ?? null);
const localShipClass = $derived<ShipClassSummary[]>(
report?.localShipClass ?? [],
);
const existingNames = $derived(localShipClass.map((c) => c.name));
const playerTech = $derived({
drive: report?.localPlayerDrive ?? 0,
weapons: report?.localPlayerWeapons ?? 0,
shields: report?.localPlayerShields ?? 0,
cargo: report?.localPlayerCargo ?? 0,
});
const techKeys: TechKey[] = ["drive", "weapons", "shields", "cargo"];
// Non-overridden tech levels track the player's current tech; the
// effect resets them whenever the report (history snapshot included)
// changes, so the calculator reflects the right turn's tech.
$effect(() => {
for (const k of techKeys) {
if (!cs.techOverridden[k]) cs.techValues[k] = playerTech[k];
}
});
// Seed the modernization target with the player's current tech once
// the report has loaded; afterwards it is the player's to edit.
$effect(() => {
if (cs.targetSeeded) return;
if (
playerTech.drive ||
playerTech.weapons ||
playerTech.shields ||
playerTech.cargo
) {
cs.targetTech = { ...playerTech };
cs.targetSeeded = true;
}
});
const result = $derived(
computeCalculator(
{
blocks: cs.blocks,
driveTech: cs.techValues.drive,
weaponsTech: cs.techValues.weapons,
shieldsTech: cs.techValues.shields,
cargoTech: cs.techValues.cargo,
loadMode: cs.loadMode,
customLoad: cs.customLoad,
lock: cs.lock === null ? null : { output: cs.lock, value: cs.lockValue },
},
core,
),
);
// Selected own planet (MVP: own planets only).
const selectedPlanet = $derived.by<ReportPlanet | null>(() => {
const sel = selection?.selected;
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
const planet = report?.planets.find((p) => p.number === sel.id) ?? null;
if (planet === null || planet.kind !== "local") return null;
return planet;
});
$effect(() => {
if (!cs.matOverridden) {
cs.matValue = selectedPlanet?.materialsStockpile ?? 0;
}
});
// With no cargo block there is no hold to load: pin the load to empty
// and disable the toggle.
const cargoEmpty = $derived(cs.blocks.cargo === 0);
$effect(() => {
if (cargoEmpty && cs.loadMode !== "empty") cs.loadMode = "empty";
});
const planetBuild = $derived.by(() => {
if (selectedPlanet === null) return null;
const emptyMass = result.outputs?.emptyMass;
if (emptyMass === undefined) return null;
return computePlanetBuild(
{
shipMass: emptyMass,
freeIndustry: selectedPlanet.freeIndustry ?? 0,
material: cs.matValue,
resources: selectedPlanet.resources ?? 0,
},
core,
);
});
// Publish the selected planet's reach (loaded speed) so the map view
// can draw 13 reach circles. Cleared when the design is invalid, no
// own planet is selected, or the calculator is in modernization mode.
$effect(() => {
const out = result.outputs;
if (cs.mode === "ship" && selectedPlanet !== null && out !== null) {
reachStore.set(
{ x: selectedPlanet.x, y: selectedPlanet.y },
out.speedLoaded,
);
} else {
reachStore.clear();
}
return () => reachStore.clear();
});
const nameInvalidKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
empty: "game.calculator.invalid.empty",
too_long: "game.calculator.invalid.too_long",
starts_with_special: "game.calculator.invalid.starts_with_special",
ends_with_special: "game.calculator.invalid.ends_with_special",
consecutive_specials: "game.calculator.invalid.consecutive_specials",
whitespace: "game.calculator.invalid.whitespace",
disallowed_character: "game.calculator.invalid.disallowed_character",
duplicate_name: "game.calculator.invalid.duplicate_name",
drive_value: "game.calculator.invalid.drive_value",
armament_value: "game.calculator.invalid.armament_value",
armament_not_integer: "game.calculator.invalid.armament_not_integer",
weapons_value: "game.calculator.invalid.weapons_value",
shields_value: "game.calculator.invalid.shields_value",
cargo_value: "game.calculator.invalid.cargo_value",
armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
all_zero: "game.calculator.invalid.all_zero",
};
const nameValidation = $derived(
validateShipClass({ name: cs.name, ...result.blocks }, { existingNames }),
);
const createMessage = $derived(
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
);
const canCreate = $derived(nameValidation.ok && draft !== undefined);
const canDelete = $derived(
cs.loadedExisting !== null &&
existingNames.includes(cs.loadedExisting) &&
draft !== undefined,
);
// Per-block modernization upgrade cost (current tech → target tech).
const modernCosts = $derived.by(() => {
if (core === null) return null;
const weaponsMass = core.weaponsBlockMass({
weapons: cs.blocks.weapons,
armament: cs.blocks.armament,
});
const rows: { key: TechKey; mass: number }[] = [
{ key: "drive", mass: cs.blocks.drive },
{ key: "weapons", mass: weaponsMass ?? 0 },
{ key: "shields", mass: cs.blocks.shields },
{ key: "cargo", mass: cs.blocks.cargo },
];
const perBlock = rows.map((r) => ({
key: r.key,
cost: core.blockUpgradeCost({
blockMass: r.mass,
currentTech: cs.techValues[r.key],
targetTech: cs.targetTech[r.key],
}),
}));
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
return { perBlock, total };
});
// Display every computed number rounded up to three decimals via the
// shared `Ceil3` bridge, so a value is never shown lower than it is.
function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable");
}
const rounded = core !== null ? core.ceil3({ value }) : value;
return rounded.toLocaleString(undefined, { maximumFractionDigits: 3 });
}
// The goal-seek back-solved block, shown in its read-only cell, is
// ceiled the same way (only the claimed block's cell is displayed).
const resolvedCeil = $derived.by(() => {
if (core === null) return result.blocks;
const c = (v: number) => core.ceil3({ value: v });
return {
drive: c(result.blocks.drive),
armament: result.blocks.armament,
weapons: c(result.blocks.weapons),
shields: c(result.blocks.shields),
cargo: c(result.blocks.cargo),
};
});
// A custom load must stay within [0, cargo capacity]; beyond that the
// ship cannot hold it.
const customLoadError = $derived.by(() => {
if (cs.loadMode !== "custom") return "";
if (cs.customLoad < 0) return i18n.t("game.calculator.invalid.negative");
if (cs.customLoad > result.cargoCapacity) {
return i18n.t("game.calculator.invalid.load_over_capacity");
}
return "";
});
const matError = $derived(
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
);
// Locking a speed back-solves the drive block; with a zero drive the
// ship is deliberately immobile, so disallow it.
function lockDisabledReason(output: LockableOutputId): string {
if (
(output === "speedEmpty" || output === "speedLoaded") &&
cs.blocks.drive === 0
) {
return i18n.t("game.calculator.lock.no_drive");
}
return "";
}
function onTechInput(key: TechKey): void {
cs.techOverridden[key] = true;
}
function onResetTech(key: TechKey): void {
cs.techOverridden[key] = false;
}
function onMatInput(): void {
cs.matOverridden = true;
}
function resetMat(): void {
cs.matOverridden = false;
}
function lockOutput(output: LockableOutputId): void {
if (cs.lock !== null) return;
cs.lockValue = result.outputs?.[output] ?? 0;
cs.lock = output;
}
function unlock(): void {
cs.lock = null;
}
function loadExisting(clsName: string): void {
const cls = localShipClass.find((c) => c.name === clsName);
if (cls === undefined) return;
cs.blocks = {
drive: cls.drive,
armament: cls.armament,
weapons: cls.weapons,
shields: cls.shields,
cargo: cls.cargo,
};
cs.name = cls.name;
cs.loadedExisting = cls.name;
cs.lock = null;
}
// React to the ship-classes table / bottom-tabs asking to load a
// class (or start a fresh design) into the calculator. The layout
// flips the sidebar to this tab in parallel.
$effect(() => {
const token = calculatorLoadRequest.token;
if (token === cs.handledLoadToken) return;
cs.handledLoadToken = token;
cs.mode = "ship";
if (calculatorLoadRequest.name === null) cs.resetDesign();
else loadExisting(calculatorLoadRequest.name);
});
async function create(): Promise<void> {
if (!nameValidation.ok || draft === undefined) return;
// Capture the validated draft before awaiting: adding the command
// re-projects `localShipClass`, which re-runs the `nameValidation`
// derived into a `duplicate_name` failure (the class now exists),
// leaving `nameValidation.value` undefined after the await.
const created = nameValidation.value;
await draft.add({
kind: "createShipClass",
id: crypto.randomUUID(),
name: created.name,
drive: created.drive,
armament: created.armament,
weapons: created.weapons,
shields: created.shields,
cargo: created.cargo,
});
cs.loadedExisting = created.name;
}
async function deleteClass(): Promise<void> {
if (cs.loadedExisting === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: cs.loadedExisting,
});
cs.loadedExisting = null;
}
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
emptyMass: i18n.t("game.calculator.out.mass"),
loadedMass: i18n.t("game.calculator.out.mass"),
speedEmpty: i18n.t("game.calculator.out.speed"),
speedLoaded: i18n.t("game.calculator.out.speed"),
attack: i18n.t("game.calculator.out.attack"),
defense: i18n.t("game.calculator.out.defense"),
});
</script>
<section class="tool" data-testid="sidebar-tool-calculator">
<h3>{i18n.t("game.sidebar.tab.calculator")}</h3>
<p>{i18n.t("game.sidebar.empty.calculator")}</p>
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
{#if cs.lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}>
<input
type="number"
step="0.001"
bind:value={cs.lockValue}
data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/>
<button
type="button"
class="lock active"
title={i18n.t("game.calculator.lock.reset")}
aria-label={i18n.t("game.calculator.lock.reset")}
data-testid={`calculator-unlock-${output}`}
onclick={unlock}
>
🔒
</button>
</span>
{:else}
{@const extra = lockDisabledReason(output)}
<span class="cell">
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
<button
type="button"
class="lock"
disabled={cs.lock !== null || value === undefined || extra !== ""}
title={cs.lock !== null
? i18n.t("game.calculator.lock.max")
: extra !== ""
? extra
: LOCK_LABELS[output]}
aria-label={LOCK_LABELS[output]}
data-testid={`calculator-lock-${output}`}
onclick={() => lockOutput(output)}
>
🔓
</button>
</span>
{/if}
{/snippet}
<section class="calculator" data-testid="sidebar-tool-calculator">
<div class="modes" role="tablist">
<button
type="button"
class:active={cs.mode === "ship"}
data-testid="calculator-mode-ship"
onclick={() => (cs.mode = "ship")}
>
{i18n.t("game.calculator.mode.ship")}
</button>
<button
type="button"
class:active={cs.mode === "modernization"}
data-testid="calculator-mode-modernization"
onclick={() => (cs.mode = "modernization")}
>
{i18n.t("game.calculator.mode.modernization")}
</button>
</div>
<div class="namebar">
<input
type="text"
class="name"
list="calculator-existing-classes"
placeholder={i18n.t("game.calculator.name.placeholder")}
maxlength="30"
bind:value={cs.name}
oninput={() => (cs.loadedExisting = null)}
onchange={() => loadExisting(cs.name)}
aria-invalid={nameValidation.ok ? "false" : "true"}
data-testid="calculator-name"
/>
<datalist id="calculator-existing-classes">
{#each localShipClass as cls (cls.name)}
<option value={cls.name}></option>
{/each}
</datalist>
{#if cs.mode === "ship"}
<button
type="button"
class="create"
disabled={!canCreate}
title={canCreate ? "" : createMessage}
data-testid="calculator-create"
onclick={() => void create()}
>
{i18n.t("game.calculator.action.create")}
</button>
{/if}
</div>
{#if cs.mode === "ship" && canDelete}
<button
type="button"
class="delete"
data-testid="calculator-delete"
onclick={() => void deleteClass()}
>
{i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
</button>
{/if}
<ShipDesignArea
bind:blocks={cs.blocks}
resolved={resolvedCeil}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
computedInput={result.computedInput}
{onTechInput}
{onResetTech}
/>
{#if cs.mode === "ship"}
<div class="load">
<span class="label">{i18n.t("game.calculator.load.label")}</span>
<div class="seg" role="group">
{#each LOAD_MODES as m (m)}
<button
type="button"
class:active={cs.loadMode === m}
disabled={cargoEmpty}
data-testid={`calculator-load-${m}`}
onclick={() => (cs.loadMode = m)}
>
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
</button>
{/each}
</div>
{#if cs.loadMode === "custom"}
<input
type="number"
step="0.01"
min="0"
class="custom-load"
bind:value={cs.customLoad}
aria-invalid={customLoadError !== "" ? "true" : "false"}
title={customLoadError}
data-testid="calculator-custom-load"
/>
{:else if cs.loadMode === "full"}
<span
class="full-capacity"
title={i18n.t("game.calculator.out.cargo_capacity")}
data-testid="calculator-full-capacity"
>
{fmt(result.cargoCapacity)}
</span>
{/if}
</div>
<div class="results" data-testid="calculator-results">
<div class="rrow head">
<span></span>
<span class="col-head">{i18n.t("game.calculator.col.empty")}</span>
<span class="col-head">{i18n.t("game.calculator.col.loaded")}</span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.mass")}</span>
{@render lockable("emptyMass", result.outputs?.emptyMass)}
{@render lockable("loadedMass", result.outputs?.loadedMass)}
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.speed")}</span>
{@render lockable("speedEmpty", result.outputs?.speedEmpty)}
{@render lockable("speedLoaded", result.outputs?.speedLoaded)}
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.attack")}</span>
{@render lockable("attack", result.outputs?.attack)}
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.defense")}</span>
{@render lockable("defense", result.outputs?.defense)}
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.bombing")}</span>
<span class="cell">
<span class="val" data-testid="calculator-out-bombing">
{fmt(result.outputs?.bombing)}
</span>
</span>
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.cargo_capacity")}</span>
<span class="cell">
<span class="val" data-testid="calculator-out-cargo-capacity">
{fmt(result.outputs === null ? null : result.cargoCapacity)}
</span>
</span>
<span></span>
</div>
</div>
<div class="planet" data-testid="calculator-planet-area">
{#if selectedPlanet === null}
<p class="hint" data-testid="calculator-planet-none">
{i18n.t("game.calculator.planet.none")}
</p>
{:else}
<p class="planet-name" data-testid="calculator-planet-name">
{i18n.t("game.calculator.planet.label", {
name: selectedPlanet.name,
number: String(selectedPlanet.number),
})}
</p>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
<span class="cell">
<input
type="number"
step="0.01"
min="0"
bind:value={cs.matValue}
oninput={onMatInput}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
{#if cs.matOverridden}
<button
type="button"
class="lock active"
title={i18n.t("game.calculator.mat.reset")}
aria-label={i18n.t("game.calculator.mat.reset")}
data-testid="calculator-mat-reset"
onclick={resetMat}
>
🔒
</button>
{/if}
</span>
<span></span>
</div>
<dl class="planet-stats">
<div>
<dt>{i18n.t("game.calculator.planet.ships_per_turn")}</dt>
<dd data-testid="calculator-ships-per-turn">
{fmt(planetBuild?.shipsPerTurn)}
</dd>
</div>
<div>
<dt>{i18n.t("game.calculator.planet.turns_per_ship")}</dt>
<dd data-testid="calculator-turns-per-ship">
{fmt(planetBuild?.turnsPerShip ?? null)}
</dd>
</div>
</dl>
{/if}
</div>
{:else}
<div class="modern" data-testid="calculator-modernization">
<div class="rrow head">
<span></span>
<span class="col-head">{i18n.t("game.calculator.modern.target")}</span>
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div>
{#each modernCosts?.perBlock ?? [] as row (row.key)}
<div class="rrow">
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
<span class="cell">
<input
type="number"
step="0.001"
min="0"
bind:value={cs.targetTech[row.key]}
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
title={cs.targetTech[row.key] < 0
? i18n.t("game.calculator.invalid.negative")
: ""}
data-testid={`calculator-target-${row.key}`}
/>
</span>
<span class="cell">
<span class="val" data-testid={`calculator-modern-cost-${row.key}`}>
{fmt(row.cost)}
</span>
</span>
</div>
{/each}
<div class="rrow total">
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
<span class="cell">
<span class="val" data-testid="calculator-modern-total">
{fmt(modernCosts?.total)}
</span>
</span>
</div>
</div>
{/if}
</section>
<style>
.tool {
padding: 1rem;
.calculator {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem;
font-family: system-ui, sans-serif;
}
.tool h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
.modes {
display: flex;
gap: 0.25rem;
}
.tool p {
.modes button {
flex: 1;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.modes button.active {
color: #e8eaf6;
border-color: #6d8cff;
background: #11162a;
}
.namebar {
display: flex;
gap: 0.35rem;
}
.name {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.4rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.name[aria-invalid="true"] {
border-color: #d97a7a;
}
.create,
.delete {
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.create:not(:disabled):hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete {
color: #d97a7a;
align-self: flex-start;
}
.load {
display: flex;
align-items: center;
gap: 0.4rem;
}
.seg {
display: flex;
gap: 0.15rem;
}
.seg button {
font: inherit;
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.seg button.active {
color: #e8eaf6;
border-color: #6d8cff;
}
.custom-load {
width: 4rem;
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.results,
.modern {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rrow {
display: grid;
grid-template-columns: 4.5rem 1fr 1fr;
align-items: center;
gap: 0.35rem;
}
.col-head {
color: #8890b0;
font-size: 0.7rem;
text-align: center;
}
.label {
color: #aab;
font-size: 0.8rem;
}
.cell {
display: flex;
align-items: center;
gap: 0.2rem;
justify-content: flex-end;
}
.cell .val {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.cell input {
width: 100%;
min-width: 0;
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.cell.locked input {
color: #9fb0ff;
border-color: #6d8cff;
}
.cell.infeasible input {
border-color: #d97a7a;
color: #f0a0a0;
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:not(:disabled):hover {
opacity: 1;
}
.lock:disabled {
cursor: not-allowed;
opacity: 0.2;
}
.planet {
border-top: 1px solid #20253a;
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hint {
margin: 0;
color: #888;
font-size: 0.8rem;
}
.planet-name {
margin: 0;
font-size: 0.8rem;
color: #cdd3f0;
}
.planet-stats {
margin: 0;
display: grid;
grid-template-columns: 1fr max-content;
row-gap: 0.2rem;
column-gap: 0.5rem;
}
.planet-stats div {
display: contents;
}
.planet-stats dt {
color: #aab;
font-size: 0.8rem;
}
.planet-stats dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.rrow.total .label {
grid-column: 1 / 3;
color: #cdd3f0;
white-space: nowrap;
}
input[aria-invalid="true"] {
border-color: #d97a7a;
}
.seg button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.full-capacity {
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
color: #9fb0ff;
}
</style>
@@ -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<ShipClassDraft, "name">;
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
@@ -139,3 +170,35 @@ function isValidDWSC(value: number): boolean {
if (!Number.isFinite(value)) return false;
return value === 0 || value >= 1;
}
/**
* shipClassFieldErrors returns the invalid reason for each offending
* block, independently, so the calculator can mark every bad input
* (not just the first failure `validateShipClassValues` reports). The
* weapons/armament pairing rule flags both fields. The all-zero rule is
* a whole-design condition and is left to `validateShipClassValues`.
*/
export function shipClassFieldErrors(
values: ShipClassValues,
): Partial<Record<keyof ShipClassValues, ShipClassValueInvalidReason>> {
const errors: Partial<
Record<keyof ShipClassValues, ShipClassValueInvalidReason>
> = {};
if (!isValidDWSC(values.drive)) errors.drive = "drive_value";
if (!Number.isFinite(values.armament) || values.armament < 0) {
errors.armament = "armament_value";
} else if (!Number.isInteger(values.armament)) {
errors.armament = "armament_not_integer";
}
if (!isValidDWSC(values.weapons)) errors.weapons = "weapons_value";
if (!isValidDWSC(values.shields)) errors.shields = "shields_value";
if (!isValidDWSC(values.cargo)) errors.cargo = "cargo_value";
if (
(values.armament === 0 && values.weapons !== 0) ||
(values.armament !== 0 && values.weapons === 0)
) {
errors.weapons ??= "armament_weapons_pair";
errors.armament ??= "armament_weapons_pair";
}
return errors;
}
+81
View File
@@ -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 13 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: 0.5,
},
});
}
return circles;
}
+148
View File
@@ -79,6 +79,76 @@ 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 Ceil3Input {
value: number;
}
export interface Core {
/**
* signRequest returns the canonical signing input bytes for a v1
@@ -174,6 +244,84 @@ 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;
/**
* ceil3 wraps `pkg/calc/number.go.Ceil3`: round a value up to three
* decimal places, for display so a computed result is never shown
* lower than it is.
*/
ceil3(input: Ceil3Input): number;
}
export type CoreLoader = () => Promise<Core>;
+56
View File
@@ -10,17 +10,29 @@
import type {
BlockUpgradeCostInput,
BombingPowerInput,
Ceil3Input,
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 +64,17 @@ 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;
ceil3(input: Ceil3Input): number;
}
interface BridgeRequestFields {
@@ -215,6 +238,39 @@ 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);
},
ceil3(input: Ceil3Input): number {
return bridge.ceil3(input);
},
};
}
@@ -43,7 +43,7 @@ the next game's snapshot — and the next game's selection — start
fresh.
-->
<script lang="ts">
import { onDestroy, onMount, setContext } from "svelte";
import { onDestroy, onMount, setContext, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Header from "$lib/header/header.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,
@@ -222,7 +223,28 @@ fresh.
$effect(() => {
const sel = selection.selected;
if (sel === null) return;
// Stay in the calculator when a planet is picked: the calculator
// consumes the selection in its planet area + reach circles, and
// it is a long-lived workspace the user should not be ejected
// from. `activeTab` is read untracked so a manual tab switch does
// not re-fire this effect. Any other case (including a ship-group
// selection, which the calculator does not use) reveals the
// inspector as before.
const tab = untrack(() => activeTab);
if (!(tab === "calculator" && sel.kind === "planet")) {
activeTab = "inspector";
}
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;
});
@@ -1,5 +0,0 @@
<script lang="ts">
import DesignerShipClass from "$lib/active-view/designer-ship-class.svelte";
</script>
<DesignerShipClass />
Binary file not shown.
+182
View File
@@ -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> = {}): 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);
});
});
+281
View File
@@ -0,0 +1,281 @@
// 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, vi } from "vitest";
// The calculator reads `page.params.id` to scope its long-lived state to
// the active game; stub a stable id so the component test has a router.
vi.mock("$app/state", () => ({ page: { params: { id: "calc-test-game" } } }));
import { i18n } from "../src/lib/i18n/index.svelte";
import CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte";
import { calculatorState } from "../src/lib/calculator/calc-state.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<GalaxyDB>;
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> = {}): 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<unknown, unknown>([
[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<void> {
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");
// The calculator state is a module singleton shared across cases.
calculatorState.reset();
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((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("—");
});
test("zero cargo disables the load toggle", async () => {
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 0);
expect(ui.getByTestId("calculator-load-full")).toBeDisabled();
expect(ui.getByTestId("calculator-load-custom")).toBeDisabled();
});
test("full load shows the cargo capacity", async () => {
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
// A fresh design starts with cargo 0, which pins load to empty;
// pick full now that there is a hold.
await fireEvent.click(ui.getByTestId("calculator-load-full"));
// capacity = cargoTech(1) * (5 + 25/20) = 6.25.
expect(ui.getByTestId("calculator-full-capacity")).toHaveTextContent("6.25");
});
test("flags a custom load above cargo capacity", 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-load-custom"));
await fireEvent.input(ui.getByTestId("calculator-custom-load"), {
target: { value: "100" },
});
expect(ui.getByTestId("calculator-custom-load")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("marks an invalid block value with aria-invalid", async () => {
const ui = mount();
// 0.5 is neither 0 nor ≥ 1.
await setBlock(ui, "drive", 0.5);
expect(ui.getByTestId("calculator-block-drive")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("disables the speed lock when drive is zero", async () => {
const ui = mount();
await setBlock(ui, "drive", 0);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
expect(ui.getByTestId("calculator-lock-speedEmpty")).toBeDisabled();
});
test("displays computed values rounded up to three decimals", async () => {
const ui = mount();
await setBlock(ui, "drive", 7);
await setBlock(ui, "shields", 3);
await setBlock(ui, "cargo", 1);
// empty mass = 11; max speed = 11 * driveTech... use a value that is
// not already 3-decimal: speedEmpty = 20*7*1.2 / 11 = 15.2727…
// ceil to 3 → 15.273.
expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent(
"15.273",
);
});
});
@@ -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<string, string>,
}));
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<GalaxyDB>;
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<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function shipClass(
overrides: Partial<ShipClassSummary> & Pick<ShipClassSummary, "name">,
): 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<unknown, unknown>([
[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"),
);
});
});
@@ -227,3 +227,90 @@ 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<number> {
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);
});
test("calculator stays put on a planet click and keeps state across tab switches", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"calculator is a desktop-sidebar flow",
);
await setupShell(page);
// Open the calculator and enter a design.
await page.getByTestId("sidebar-tab-calculator").click();
await page.getByTestId("calculator-block-drive").fill("10");
// Clicking a planet must NOT eject us to the inspector; it feeds the
// calculator's planet area instead, and the design is untouched.
await clickCanvasCentre(page);
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
await expect(page.getByTestId("calculator-planet-name")).toContainText(
"Galactica",
);
await expect(page.getByTestId("calculator-block-drive")).toHaveValue("10");
// Switching to the inspector and back keeps the design (long-lived
// tool state survives the tab unmount/remount).
await page.getByTestId("sidebar-tab-inspector").click();
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"inspector",
);
await page.getByTestId("sidebar-tab-calculator").click();
await expect(page.getByTestId("calculator-block-drive")).toHaveValue("10");
});
-5
View File
@@ -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",
@@ -161,7 +161,6 @@ async function readPrimitiveCount(page: Page): Promise<number> {
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" },
];
+42 -47
View File
@@ -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<void> {
);
}
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(
+129
View File
@@ -0,0 +1,129 @@
// 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> = {}): 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,
ceil3: ({ value }) => Math.ceil(Math.round(value * 1e9) / 1e6) / 1000,
};
return { ...base, ...overrides };
}
+5 -10
View File
@@ -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<typeof vi.fn>;
} {
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,
};
}
@@ -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",
+6 -5
View File
@@ -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.
@@ -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(
+55
View File
@@ -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]);
});
});
+9 -4
View File
@@ -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();
});
});
+79
View File
@@ -0,0 +1,79 @@
// 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();
});
test("ceil3 rounds up to three decimals", () => {
expect(core.ceil3({ value: 5.0003 })).toBeCloseTo(5.001, 9);
expect(core.ceil3({ value: 4.2761 })).toBeCloseTo(4.277, 9);
expect(core.ceil3({ value: 5 })).toBeCloseTo(5, 9);
});
});
+189
View File
@@ -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
@@ -61,6 +76,17 @@ func main() {
"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),
"ceil3": js.FuncOf(ceil3),
}))
// Block forever so the Go runtime stays alive while JS keeps calling
@@ -241,6 +267,169 @@ 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)
}
// ceil3 bridges `calc.Ceil3`. Input `{ value }`, output a JS number
// rounded up to three decimal places.
func ceil3(_ js.Value, args []js.Value) any {
if len(args) != 1 {
return js.Null()
}
return js.ValueOf(calc.Ceil3(args[0].Get("value").Float()))
}
// 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