Phase 30 — Ship Class Calculator (goal-seek, reach circles, planet build) #24
@@ -272,29 +272,19 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
|
|||||||
if productionAvailable <= 0 {
|
if productionAvailable <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
ships := uint(0)
|
ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn(
|
||||||
pa := productionAvailable
|
productionAvailable,
|
||||||
var MATneed, totalCost float64
|
float64(p.Material),
|
||||||
for {
|
float64(p.Resources),
|
||||||
MATneed = shipMass - float64(p.Material)
|
shipMass,
|
||||||
if MATneed < 0 {
|
)
|
||||||
MATneed = 0
|
p.Mat(materialLeft)
|
||||||
}
|
pval := game.F(progress)
|
||||||
totalCost = calc.ShipBuildCost(shipMass, float64(p.Material), float64(p.Resources))
|
if p.Production.Progress != nil {
|
||||||
if pa < totalCost {
|
pval += *p.Production.Progress
|
||||||
progress := pa / totalCost
|
|
||||||
pval := game.F(progress)
|
|
||||||
if p.Production.Progress != nil {
|
|
||||||
pval += *p.Production.Progress
|
|
||||||
}
|
|
||||||
p.Production.Progress = &pval
|
|
||||||
fval := game.F(pa)
|
|
||||||
p.Production.ProdUsed = &fval
|
|
||||||
return ships
|
|
||||||
} else {
|
|
||||||
pa -= totalCost
|
|
||||||
p.Mat(float64(p.Material) - shipMass + MATneed)
|
|
||||||
ships += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
p.Production.Progress = &pval
|
||||||
|
used := game.F(productionUsed)
|
||||||
|
p.Production.ProdUsed = &used
|
||||||
|
return ships
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package game
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"galaxy/calc"
|
"galaxy/calc"
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -208,11 +207,12 @@ func (sg ShipGroup) Speed(st *ShipType) float64 {
|
|||||||
|
|
||||||
// Мощность бомбардировки
|
// Мощность бомбардировки
|
||||||
func (sg ShipGroup) BombingPower(st *ShipType) float64 {
|
func (sg ShipGroup) BombingPower(st *ShipType) float64 {
|
||||||
return (math.Sqrt(st.Weapons.F()*sg.TechLevel(TechWeapons).F())/10. + 1.) *
|
return calc.BombingPower(
|
||||||
st.Weapons.F() *
|
st.Weapons.F(),
|
||||||
sg.TechLevel(TechWeapons).F() *
|
sg.TechLevel(TechWeapons).F(),
|
||||||
float64(st.Armament) *
|
float64(st.Armament),
|
||||||
float64(sg.Number)
|
float64(sg.Number),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sg ShipGroup) CargoString() string {
|
func (sg ShipGroup) CargoString() string {
|
||||||
|
|||||||
@@ -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
@@ -22,10 +22,11 @@ func PlanetProduceShipMass(L, Mat, Res float64) float64 {
|
|||||||
// resources is expected to be positive in normal play; the helper
|
// resources is expected to be positive in normal play; the helper
|
||||||
// guards against a non-positive value by collapsing the material-
|
// guards against a non-positive value by collapsing the material-
|
||||||
// farming term to zero, which keeps callers numerically stable on
|
// farming term to zero, which keeps callers numerically stable on
|
||||||
// pathological synthetic data. Mirrors the per-iteration math inside
|
// pathological synthetic data. [ProduceShipsInTurn] composes this cost
|
||||||
// the engine's controller.ProduceShip so both surfaces — and the
|
// into the per-turn build loop that the engine's controller.ProduceShip
|
||||||
// legacy-report-to-json dev tool that needs to derive prod_used from
|
// delegates to, so the engine, the calculator, and the
|
||||||
// percent — share the same formula.
|
// legacy-report-to-json dev tool (which derives prod_used from percent)
|
||||||
|
// all share one formula.
|
||||||
func ShipBuildCost(shipMass, material, resources float64) float64 {
|
func ShipBuildCost(shipMass, material, resources float64) float64 {
|
||||||
matNeed := shipMass - material
|
matNeed := shipMass - material
|
||||||
if matNeed < 0 {
|
if matNeed < 0 {
|
||||||
@@ -37,3 +38,39 @@ func ShipBuildCost(shipMass, material, resources float64) float64 {
|
|||||||
}
|
}
|
||||||
return ShipProductionCost(shipMass) + matFarm
|
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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,3 +94,14 @@ func EffectiveDefence(
|
|||||||
}
|
}
|
||||||
return defendingShields * defendingShiledsTech / math.Pow(defendingFullMass, 1./3.) * math.Pow(30., 1./3.)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -1968,7 +1968,11 @@ Decisions baked into Phase 16 (vs. the original stage description):
|
|||||||
|
|
||||||
## ~~Phase 17. Ship Classes — CRUD Without Calc~~
|
## ~~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
|
Goal: list, view, create, and delete ship classes through a
|
||||||
dedicated table view and a designer view; numeric calculations are
|
dedicated table view and a designer view; numeric calculations are
|
||||||
@@ -2037,7 +2041,10 @@ Targeted tests:
|
|||||||
|
|
||||||
## ~~Phase 18. Ship Classes — Calc Bridge~~
|
## ~~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,
|
Goal: wire `pkg/calc/` ship math into the designer for live mass,
|
||||||
speed, range, and cargo capacity previews.
|
speed, range, and cargo capacity previews.
|
||||||
@@ -3314,39 +3321,93 @@ Decisions:
|
|||||||
rendered result is verified by a high-contrast screenshot during
|
rendered result is verified by a high-contrast screenshot during
|
||||||
development plus the existing fog / render-on-demand e2e.
|
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
|
Goal: replace the standalone Phase 17/18 ship-class designer with a
|
||||||
view, exposing the full set of `pkg/calc/` functions wired through
|
fused designer + calculator living in the sidebar. It shows the ship
|
||||||
`Core`.
|
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:
|
Artifacts:
|
||||||
|
|
||||||
- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` UI with mode
|
- `pkg/calc/` additions, single-sourced (no mirroring): `BombingPower`
|
||||||
selector (ship calculator, path calculator, modernization cost,
|
extracted from `game/internal/model/game/group.go`; a pure
|
||||||
bombing power) and per-mode forms
|
`ProduceShipsInTurn` extracted from `controller.ProduceShip` (the
|
||||||
- bridge entries in `ui/core/calc/` for any function not already
|
engine now delegates to both); inverse solvers in `pkg/calc/solve.go`
|
||||||
wrapped by Phase 18
|
(`WeaponsForAttack`, `DriveForSpeed`, `ShieldsForDefence` by
|
||||||
- topic doc `ui/docs/calculator-ux.md` documenting modes,
|
bisection, `CargoForEmptyMass`, `LoadForFullMass`)
|
||||||
layouts, and the rule that calculator inputs persist across
|
- thin bridges in `ui/core/calc/` (combat, planet build, solvers),
|
||||||
navigation
|
registered in `ui/wasm/main.go`, typed on `Core`
|
||||||
|
(`platform/core/index.ts` + `wasm.ts`)
|
||||||
|
- `ui/frontend/src/lib/calculator/calc-model.ts` pure orchestration
|
||||||
|
(forward results + single-target goal-seek + planet build)
|
||||||
|
- `ui/frontend/src/lib/calculator/ship-design-area.svelte` reusable
|
||||||
|
design block (5 blocks + 4 techs, override locks, computed-block
|
||||||
|
read-only) — earmarked for the future ship-group upgrade flow
|
||||||
|
- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` shell (mode
|
||||||
|
selector, name combobox + Create / Delete, the calc and planet areas
|
||||||
|
inline)
|
||||||
|
- `ui/frontend/src/map/reach-circles.ts` + `lib/calculator/reach.svelte.ts`
|
||||||
|
shared store: the calculator publishes the selected planet origin and
|
||||||
|
loaded speed, the map draws 1–3 reach circles
|
||||||
|
- `lib/calculator/load-request.svelte.ts` shared store: the table /
|
||||||
|
menus ask the layout to open the calculator on a class
|
||||||
|
- topic doc `ui/docs/calculator-ux.md`; `ui/docs/calc-bridge.md`
|
||||||
|
extended with the new wired functions
|
||||||
|
|
||||||
Dependencies: Phase 18.
|
Dependencies: Phases 17, 18, 19/20 (selection store), 29 (map modes).
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- every calculator mode produces results identical to direct
|
- every result is byte-identical to direct `pkg/calc/` calls on shared
|
||||||
`pkg/calc/` calls;
|
fixtures (Go parity tests);
|
||||||
- inputs persist across view switches per global state-preservation
|
- locking one result back-solves its claimed input; a second lock is
|
||||||
rule;
|
disabled; an unreachable target shows the error state;
|
||||||
- calculator works in history mode against the snapshot's tech levels.
|
- 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:
|
Targeted tests:
|
||||||
|
|
||||||
- Vitest snapshot tests per mode on canonical inputs;
|
- Go: `pkg/calc` unit tests + engine parity (`ProduceShip`,
|
||||||
- Playwright e2e: switch modes, confirm input persistence.
|
`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
|
## Phase 31. Wails Desktop Wrapper
|
||||||
|
|
||||||
@@ -3483,54 +3544,56 @@ Targeted tests:
|
|||||||
- regression test: bumping the app version invalidates the prior
|
- regression test: bumping the app version invalidates the prior
|
||||||
service worker.
|
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
|
Status: pending. Long-term scope deferred but this phase ships real
|
||||||
features.
|
features.
|
||||||
|
|
||||||
Goal: ship two concrete projection features (planet next-turn
|
Goal: ship a realistic multi-turn planet projection and surface it in
|
||||||
forecast and ship-designer reach circles) plus the transient
|
the planet inspector and in the calculator's planet area. Reach circles
|
||||||
map-overlay back-stack mechanism that the reach-circles feature is
|
already shipped in Phase 30 (auto-drawn from the calculator's selected
|
||||||
the first user of.
|
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:
|
Artifacts:
|
||||||
|
|
||||||
- `ui/frontend/src/lib/projection/` minimal projection engine that
|
- `pkg/calc/` planet-economy extraction (single-sourced, engine
|
||||||
computes one-turn-ahead state for a single planet using `pkg/calc/`
|
delegates): `PlanetProduction`, `ProducePopulation`,
|
||||||
- planet inspector forecast section showing next-turn population,
|
`UnpackColonists`, `UnpackCapital`, reusing `ProduceShipsInTurn`; a
|
||||||
industry, materials stockpile, and production progress
|
multi-turn projector `ProjectPlanetBuild` answering "K ships in M
|
||||||
- `ui/frontend/src/lib/navigation/transient-overlay.ts` push/pop
|
turns" under guaranteed per-turn supply
|
||||||
back-stack mechanism for map overlays driven by other views, with
|
- thin bridges in `ui/core/calc/` + `Core` typings
|
||||||
a back-button affordance on the map that returns to the originating
|
- planet inspector forecast section (next-turn population, industry,
|
||||||
view with state preserved
|
materials, production progress)
|
||||||
- ship-designer `Preview range on map` action that pushes a transient
|
- calculator planet area gains CAP and COL supply inputs and switches
|
||||||
overlay onto the map showing concentric reach circles for 1, 2, 3,
|
its readout to the multi-turn projector
|
||||||
4 turns from a chosen origin, computed from the in-progress ship
|
- topic doc `ui/docs/multi-turn-projection.md` (long-term vision:
|
||||||
design and the player's current Drive tech via `ui/core/calc/`
|
multi-turn planning mode, scenario branches)
|
||||||
- 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
|
|
||||||
|
|
||||||
Dependencies: Phases 17, 18.
|
Dependencies: Phases 17, 18, 30.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- the planet inspector shows a forecast section with next-turn values
|
- projector output is byte-identical to running the engine's per-turn
|
||||||
matching `pkg/calc/` outputs;
|
planet update over the same turns (Go parity);
|
||||||
- the ship-designer `Preview range on map` button transitions to the
|
- the planet inspector shows a forecast section matching it;
|
||||||
map with reach circles drawn from the chosen origin; back returns
|
- the calculator planet area honours MAT / CAP / COL supply and shows
|
||||||
to the designer with all in-progress state intact;
|
"K ships in M turns" consistent with the projector.
|
||||||
- the transient overlay is cleared if the user navigates to any other
|
|
||||||
view via the header dropdown.
|
|
||||||
|
|
||||||
Targeted tests:
|
Targeted tests:
|
||||||
|
|
||||||
- Vitest unit tests for the projection engine on canonical fixtures;
|
- Go parity tests for each extracted economy formula and the projector;
|
||||||
- Vitest unit tests for the transient-overlay push/pop logic and
|
- Vitest for the calculator planet area with supply inputs;
|
||||||
state preservation;
|
- Playwright e2e: planet inspector forecast section.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Phase 35. Polish — Accessibility, Localisation, Error UX
|
## Phase 35. Polish — Accessibility, Localisation, Error UX
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,3 +65,26 @@ func CarryingMass(load, cargoTech float64) float64 {
|
|||||||
func BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech float64) float64 {
|
func BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech float64) float64 {
|
||||||
return calc.BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+73
-10
@@ -28,28 +28,28 @@ type shipFixture struct {
|
|||||||
func fixtures() []shipFixture {
|
func fixtures() []shipFixture {
|
||||||
return []shipFixture{
|
return []shipFixture{
|
||||||
{
|
{
|
||||||
name: "all_zero",
|
name: "all_zero",
|
||||||
drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0,
|
drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0,
|
||||||
driveTech: 1, cargoTech: 1,
|
driveTech: 1, cargoTech: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "typical_mid_tech",
|
name: "typical_mid_tech",
|
||||||
drive: 8, armament: 2, weapons: 5, shields: 3, cargo: 4,
|
drive: 8, armament: 2, weapons: 5, shields: 3, cargo: 4,
|
||||||
driveTech: 1.5, cargoTech: 1.2,
|
driveTech: 1.5, cargoTech: 1.2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "heavy_armoured",
|
name: "heavy_armoured",
|
||||||
drive: 3, armament: 5, weapons: 12, shields: 20, cargo: 1,
|
drive: 3, armament: 5, weapons: 12, shields: 20, cargo: 1,
|
||||||
driveTech: 0.8, cargoTech: 0.5,
|
driveTech: 0.8, cargoTech: 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid_weapons_no_armament",
|
name: "invalid_weapons_no_armament",
|
||||||
drive: 5, armament: 0, weapons: 4, shields: 1, cargo: 2,
|
drive: 5, armament: 0, weapons: 4, shields: 1, cargo: 2,
|
||||||
driveTech: 1, cargoTech: 1,
|
driveTech: 1, cargoTech: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid_armament_no_weapons",
|
name: "invalid_armament_no_weapons",
|
||||||
drive: 5, armament: 3, weapons: 0, shields: 1, cargo: 2,
|
drive: 5, armament: 3, weapons: 0, shields: 1, cargo: 2,
|
||||||
driveTech: 1, cargoTech: 1,
|
driveTech: 1, cargoTech: 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -236,3 +236,66 @@ func TestDesignerPreviewComposition(t *testing.T) {
|
|||||||
assert.Equal(t, wantMaxSpeed, maxSpeed)
|
assert.Equal(t, wantMaxSpeed, maxSpeed)
|
||||||
assert.Equal(t, wantRange, rangePerTurn)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -11,11 +11,14 @@ matching TS adapter in `ui/frontend/src/platform/core/`.
|
|||||||
Phase 18 lands the **ship-math slice** of the bridge — everything
|
Phase 18 lands the **ship-math slice** of the bridge — everything
|
||||||
the ship-class designer needs to render its preview pane. Phase 20
|
the ship-class designer needs to render its preview pane. Phase 20
|
||||||
extends it with `BlockUpgradeCost` so the ship-group inspector can
|
extends it with `BlockUpgradeCost` so the ship-group inspector can
|
||||||
preview modernize cost. Other slices (production forecast, science
|
preview modernize cost. Phase 30 extends it with the **combat,
|
||||||
research, ship build progress) remain deferred to dedicated future
|
planet-build, and goal-seek slice** for the ship-class calculator:
|
||||||
phases. This document is the running audit trail of what is live,
|
`EffectiveAttack`, `EffectiveDefence`, `BombingPower`, `ShipBuildCost`,
|
||||||
what is missing, and how each function maps to its `pkg/calc/`
|
`ProduceShipsInTurn`, and the inverse solvers from `pkg/calc/solve.go`.
|
||||||
source.
|
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
|
## 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) |
|
| `speed` | `calc.Speed(driveEffective, fullMass)` | `number` | designer preview (speed + range) |
|
||||||
| `cargoCapacity` | `calc.CargoCapacity(cargo, cargoTech)` | `number` | designer preview (cargo row) |
|
| `cargoCapacity` | `calc.CargoCapacity(cargo, cargoTech)` | `number` | designer preview (cargo row) |
|
||||||
| `carryingMass` | `calc.CarryingMass(load, cargoTech)` | `number` | designer preview (full-load mass) |
|
| `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
|
`number|null` returns mirror the Go `(float64, bool)` signature: the
|
||||||
upstream validator rejects weapons/armament pairings with one zero
|
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-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-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 |
|
| Free production potential (`freeIndustry`) | `Planet.ProductionCapacity` → `industry*0.75 + population*0.25` (`game/internal/model/game/planet.go`) | no | no |
|
||||||
| Industry production output per turn | `Planet.ProduceIndustry(freeProduction)` (`planet.go`); `freeProduction/5` modulo material constraint | no | no |
|
| Industry production output per turn | `Planet.ProduceIndustry(freeProduction)` (`planet.go`); `freeProduction/5` modulo material constraint | no | no |
|
||||||
| Materials production output per turn | `Planet.ProduceMaterial(freeProduction)` (`planet.go`); `freeProduction * resources` | no | no |
|
| Materials production output per turn | `Planet.ProduceMaterial(freeProduction)` (`planet.go`); `freeProduction * resources` | no | no |
|
||||||
| Per-tech research progress (DRIVE/WEAPONS/…) | `ResearchTech` (`game/internal/model/game/science.go`); `freeProduction / 5000` per tech level | 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 |
|
| 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
|
`partial` means the Go primitives exist in `pkg/calc/` but the
|
||||||
composition (and the conversion of TS-side `ReportPlanet`/
|
composition (and the conversion of TS-side `ReportPlanet`/
|
||||||
|
|||||||
@@ -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 1–3 thin concentric
|
||||||
|
reach circles (`map/reach-circles.ts`) for 1/2/3 turns. The ring count
|
||||||
|
shrinks as speed grows: a ring is dropped once the previous one reaches
|
||||||
|
the torus wrap-midpoint (half the shorter side) or the no-wrap map edge
|
||||||
|
(farthest corner). The circles clear when the selection clears or the
|
||||||
|
design is invalid.
|
||||||
|
|
||||||
|
## State preservation and history
|
||||||
|
|
||||||
|
Calculator inputs are component-local state. The sidebar keeps the tab
|
||||||
|
mounted while the player navigates between active views, so inputs
|
||||||
|
persist across view switches per the global state-preservation rule
|
||||||
|
(`ui/docs/navigation.md`). Tech levels track the rendered report, so in
|
||||||
|
history mode the calculator computes against the viewed snapshot's tech.
|
||||||
|
|
||||||
|
The ship-classes table and the view/bottom menus open the calculator via
|
||||||
|
a shared request store (`lib/calculator/load-request.svelte`): the
|
||||||
|
in-game layout flips the sidebar to the calculator tab and the
|
||||||
|
calculator loads the requested class (or starts a fresh design).
|
||||||
|
|
||||||
|
## Layout and mobile
|
||||||
|
|
||||||
|
Everything stacks vertically to fit the 18 rem sidebar; the design and
|
||||||
|
result rows use compact two-column (ship/tech, empty/loaded) grids. On
|
||||||
|
mobile the sidebar is the existing bottom-sheet/overlay; the calc bottom
|
||||||
|
tab opens it.
|
||||||
|
|
||||||
|
## Runtime note
|
||||||
|
|
||||||
|
The new bridge functions are only present after `make wasm` rebuilds
|
||||||
|
`ui/frontend/static/core.wasm` (needs TinyGo). Vitest injects a fake
|
||||||
|
`Core` (`tests/fake-core.ts`) mirroring `pkg/calc`, so unit/component
|
||||||
|
tests do not need the rebuild; the Playwright suite and the live app do.
|
||||||
+13
-11
@@ -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/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/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
|
||||||
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
|
| `/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/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` | Phase 21 |
|
||||||
|
|
||||||
`/games/:id` (no trailing view) redirects to `/games/:id/map`. The
|
`/games/:id` (no trailing view) redirects to `/games/:id/map`. The
|
||||||
optional `:classId?` / `:scienceId?` segments on the designer
|
optional `:scienceId?` segment on the science designer route matches
|
||||||
routes match SvelteKit's `[[classId]]` syntax — `/designer/ship-class`
|
SvelteKit's `[[scienceId]]` syntax — `/designer/science` opens the
|
||||||
opens the empty new-class form, `/designer/ship-class/{name}`
|
empty new-science form, `/designer/science/{name}` opens the named
|
||||||
opens the read-only view of the named class with the Delete
|
science. Phase 17/18 originally added a parallel ship-class designer
|
||||||
affordance. Phase 17 lights up the ship-class CRUD path; Phase 18
|
route; Phase 30 removed it and folded ship-class design into the
|
||||||
adds the live `pkg/calc/`-backed preview pane on top.
|
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`,
|
The `entity` slug on the table route is kebab-case (`planets`,
|
||||||
`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`).
|
`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
|
## Transient map overlays
|
||||||
|
|
||||||
Some views can push a transient overlay onto `/map` with a back
|
Some views can push a transient overlay onto `/map` with a back
|
||||||
affordance — for example, the ship-class designer pushes a
|
affordance. (Phase 30's calculator reach circles are a simpler,
|
||||||
range-preview overlay onto the map. The transient overlay clears
|
always-on map extra rather than a back-stacked overlay; the transient
|
||||||
when the user navigates to any other view via the header or the
|
back-stack mechanism itself is still a Phase 34 concept.) A transient
|
||||||
bottom-tabs.
|
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
|
Phase 10 documents this concept but does not implement the
|
||||||
back-stack mechanism. Phase 34 lands the back-stack alongside its
|
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,6 +31,8 @@ preference the store already manages.
|
|||||||
} from "../../map/index";
|
} from "../../map/index";
|
||||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
||||||
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
||||||
|
import { computeReachCircles } from "../../map/reach-circles";
|
||||||
|
import { reachStore } from "$lib/calculator/reach.svelte";
|
||||||
import {
|
import {
|
||||||
reportToWorld,
|
reportToWorld,
|
||||||
type HitTarget,
|
type HitTarget,
|
||||||
@@ -196,6 +198,11 @@ preference the store already manages.
|
|||||||
void toggles.bombingMarkers;
|
void toggles.bombingMarkers;
|
||||||
void toggles.visibleHyperspace;
|
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-
|
// Phase 29 visibility derivation. Cargo routes and pending-
|
||||||
// Send overlay are extras (no Pixi remount on flip); the
|
// Send overlay are extras (no Pixi remount on flip); the
|
||||||
// cascade-filtering happens here so the extras list shrinks
|
// 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.
|
// the visible set reliably triggers a push.
|
||||||
const draftCommands = orderDraft?.commands ?? [];
|
const draftCommands = orderDraft?.commands ?? [];
|
||||||
const draftStatuses = orderDraft?.statuses ?? {};
|
const draftStatuses = orderDraft?.statuses ?? {};
|
||||||
|
const reachOrigin = reachStore.origin;
|
||||||
|
const reachFingerprint =
|
||||||
|
reachOrigin === null
|
||||||
|
? ""
|
||||||
|
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
|
||||||
const extrasFingerprint =
|
const extrasFingerprint =
|
||||||
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
||||||
|
`reach=${reachFingerprint}|` +
|
||||||
computeRoutesFingerprint(report.routes) +
|
computeRoutesFingerprint(report.routes) +
|
||||||
"|" +
|
"|" +
|
||||||
computePendingSendFingerprint(draftCommands, draftStatuses);
|
computePendingSendFingerprint(draftCommands, draftStatuses);
|
||||||
@@ -256,6 +269,7 @@ preference the store already manages.
|
|||||||
draftStatuses,
|
draftStatuses,
|
||||||
toggles,
|
toggles,
|
||||||
hiddenPlanetNumbers,
|
hiddenPlanetNumbers,
|
||||||
|
mode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -289,6 +303,7 @@ preference the store already manages.
|
|||||||
draftStatuses: Readonly<Record<string, string>>,
|
draftStatuses: Readonly<Record<string, string>>,
|
||||||
toggles: MapToggles,
|
toggles: MapToggles,
|
||||||
hiddenPlanetNumbers: ReadonlySet<number>,
|
hiddenPlanetNumbers: ReadonlySet<number>,
|
||||||
|
mode: "torus" | "no-wrap",
|
||||||
): import("../../map/world").Primitive[] {
|
): import("../../map/world").Primitive[] {
|
||||||
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
|
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
|
||||||
const cargo = toggles.cargoRoutes
|
const cargo = toggles.cargoRoutes
|
||||||
@@ -300,7 +315,21 @@ preference the store already manages.
|
|||||||
draftStatuses,
|
draftStatuses,
|
||||||
skip ? { skipPlanets: skip } : undefined,
|
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(
|
function applyVisibilityState(
|
||||||
@@ -342,6 +371,7 @@ preference the store already manages.
|
|||||||
draftStatuses,
|
draftStatuses,
|
||||||
toggles,
|
toggles,
|
||||||
hiddenPlanetNumbers,
|
hiddenPlanetNumbers,
|
||||||
|
mode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
lastExtrasFingerprint = extrasFingerprint;
|
lastExtrasFingerprint = extrasFingerprint;
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ data fetching is performed here — the layout is responsible.
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
import type { ShipClassSummary } from "../../api/game-state";
|
import type { ShipClassSummary } from "../../api/game-state";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
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,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../../sync/order-draft.svelte";
|
} from "../../sync/order-draft.svelte";
|
||||||
|
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
|
||||||
|
|
||||||
type SortColumn =
|
type SortColumn =
|
||||||
| "name"
|
| "name"
|
||||||
@@ -62,7 +61,6 @@ data fetching is performed here — the layout is responsible.
|
|||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
|
||||||
|
|
||||||
let sortColumn: SortColumn = $state("name");
|
let sortColumn: SortColumn = $state("name");
|
||||||
let sortDirection: SortDirection = $state("asc");
|
let sortDirection: SortDirection = $state("asc");
|
||||||
@@ -111,14 +109,12 @@ data fetching is performed here — the layout is responsible.
|
|||||||
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDesigner(name: string): void {
|
function openInCalculator(name: string): void {
|
||||||
void goto(
|
calculatorLoadRequest.request(name);
|
||||||
`/games/${gameId}/designer/ship-class/${encodeURIComponent(name)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newShipClass(): void {
|
function newShipClass(): void {
|
||||||
void goto(`/games/${gameId}/designer/ship-class`);
|
calculatorLoadRequest.request(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteShipClass(name: string): Promise<void> {
|
async function deleteShipClass(name: string): Promise<void> {
|
||||||
@@ -194,7 +190,7 @@ data fetching is performed here — the layout is responsible.
|
|||||||
<tr
|
<tr
|
||||||
data-testid="ship-classes-row"
|
data-testid="ship-classes-row"
|
||||||
data-name={cls.name}
|
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-name">{cls.name}</td>
|
||||||
<td data-testid="ship-classes-cell-drive">{formatNumber(cls.drive)}</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>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -337,6 +337,69 @@ const en = {
|
|||||||
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
|
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
|
||||||
"game.designer.ship_class.preview.unavailable": "—",
|
"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.title": "sciences",
|
||||||
"game.table.sciences.column.name": "name",
|
"game.table.sciences.column.name": "name",
|
||||||
"game.table.sciences.column.drive": "drive %",
|
"game.table.sciences.column.drive": "drive %",
|
||||||
|
|||||||
@@ -338,6 +338,69 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
|
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
|
||||||
"game.designer.ship_class.preview.unavailable": "—",
|
"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.title": "науки",
|
||||||
"game.table.sciences.column.name": "название",
|
"game.table.sciences.column.name": "название",
|
||||||
"game.table.sciences.column.drive": "двигатель %",
|
"game.table.sciences.column.drive": "двигатель %",
|
||||||
|
|||||||
@@ -179,14 +179,6 @@ destinations beats the duplication.
|
|||||||
>
|
>
|
||||||
{i18n.t("game.view.mail")}
|
{i18n.t("game.view.mail")}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -1,29 +1,902 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 10 stub for the Calculator sidebar tool. Phase 30 wires the
|
Phase 30 ship-class calculator. Replaces the Phase 17/18 standalone
|
||||||
real ship/path calculator. Until then the stub renders a localised
|
designer: it fuses the ship design blocks, the live derived results
|
||||||
`coming soon` paragraph with a stable testid that later phases can
|
(mass, speed, attack, defence, bombing), and a planet build-rate readout
|
||||||
replace without touching navigation.
|
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">
|
<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 1–3 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>
|
</script>
|
||||||
|
|
||||||
<section class="tool" data-testid="sidebar-tool-calculator">
|
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
|
||||||
<h3>{i18n.t("game.sidebar.tab.calculator")}</h3>
|
{#if cs.lock === output}
|
||||||
<p>{i18n.t("game.sidebar.empty.calculator")}</p>
|
<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>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tool {
|
.calculator {
|
||||||
padding: 1rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.75rem;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.tool h3 {
|
.modes {
|
||||||
margin: 0 0 0.5rem;
|
display: flex;
|
||||||
font-size: 1rem;
|
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;
|
margin: 0;
|
||||||
color: #888;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// TS port of `pkg/calc/validator.go.ValidateShipTypeValues` plus a
|
// TS port of `pkg/calc/validator.go.ValidateShipTypeValues` plus a
|
||||||
// thin wrapper that runs the entity-name rules and a duplicate-name
|
// thin wrapper that runs the entity-name rules and a duplicate-name
|
||||||
// check against the live `localShipClass` projection. The validator
|
// 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`
|
// for inline error messages and by `OrderDraftStore.validateCommand`
|
||||||
// to gate auto-sync, so the local invariants match the engine's
|
// to gate auto-sync, so the local invariants match the engine's
|
||||||
// (`game/internal/controller/ship_class.go.ShipClassCreate`).
|
// (`game/internal/controller/ship_class.go.ShipClassCreate`).
|
||||||
@@ -33,9 +33,12 @@ import {
|
|||||||
* translation keys for those branches and adds new keys only for
|
* translation keys for those branches and adds new keys only for
|
||||||
* the value-derived ones.
|
* the value-derived ones.
|
||||||
*/
|
*/
|
||||||
export type ShipClassInvalidReason =
|
/**
|
||||||
| EntityNameInvalidReason
|
* ShipClassValueInvalidReason enumerates the value-only refusals (no
|
||||||
| "duplicate_name"
|
* 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"
|
| "drive_value"
|
||||||
| "armament_value"
|
| "armament_value"
|
||||||
| "armament_not_integer"
|
| "armament_not_integer"
|
||||||
@@ -45,6 +48,11 @@ export type ShipClassInvalidReason =
|
|||||||
| "armament_weapons_pair"
|
| "armament_weapons_pair"
|
||||||
| "all_zero";
|
| "all_zero";
|
||||||
|
|
||||||
|
export type ShipClassInvalidReason =
|
||||||
|
| EntityNameInvalidReason
|
||||||
|
| "duplicate_name"
|
||||||
|
| ShipClassValueInvalidReason;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ShipClassDraft is the structural shape the designer composes. The
|
* ShipClassDraft is the structural shape the designer composes. The
|
||||||
* five numeric fields carry the player's typed values verbatim;
|
* five numeric fields carry the player's typed values verbatim;
|
||||||
@@ -60,10 +68,17 @@ export interface ShipClassDraft {
|
|||||||
cargo: number;
|
cargo: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ShipClassValues is the five-block subset validated by value rules. */
|
||||||
|
export type ShipClassValues = Omit<ShipClassDraft, "name">;
|
||||||
|
|
||||||
export type ShipClassValidation =
|
export type ShipClassValidation =
|
||||||
| { ok: true; value: ShipClassDraft }
|
| { ok: true; value: ShipClassDraft }
|
||||||
| { ok: false; reason: ShipClassInvalidReason };
|
| { ok: false; reason: ShipClassInvalidReason };
|
||||||
|
|
||||||
|
export type ShipClassValuesValidation =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; reason: ShipClassValueInvalidReason };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* validateShipClass mirrors `ValidateShipTypeValues` plus the
|
* validateShipClass mirrors `ValidateShipTypeValues` plus the
|
||||||
* entity-name rules. `existingNames` is the optimistic projection of
|
* entity-name rules. `existingNames` is the optimistic projection of
|
||||||
@@ -84,38 +99,9 @@ export function validateShipClass(
|
|||||||
}
|
}
|
||||||
const trimmedName = nameResult.value;
|
const trimmedName = nameResult.value;
|
||||||
|
|
||||||
if (!isValidDWSC(draft.drive)) {
|
const valueResult = validateShipClassValues(draft);
|
||||||
return { ok: false, reason: "drive_value" };
|
if (!valueResult.ok) {
|
||||||
}
|
return { ok: false, reason: valueResult.reason };
|
||||||
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 existing = options.existingNames ?? [];
|
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`:
|
* isValidDWSC mirrors `pkg/calc/validator.go.CheckShipTypeValueDWSC`:
|
||||||
* a Drive / Weapons / Shields / Cargo value is acceptable only when
|
* 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;
|
if (!Number.isFinite(value)) return false;
|
||||||
return value === 0 || value >= 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Phase 30 reach circles. When the ship-class calculator has a planet
|
||||||
|
// selected and a valid design, it publishes the design's loaded speed and
|
||||||
|
// the planet origin to `lib/calculator/reach.svelte`; the map view reads
|
||||||
|
// that store and feeds it through `computeReachCircles` to draw 1–3 thin
|
||||||
|
// concentric rings showing how far the ship reaches in 1, 2, and 3 turns.
|
||||||
|
//
|
||||||
|
// The ring count is bounded by how soon a ring reaches the meaningful
|
||||||
|
// extent of the map: half the shorter side on a torus (beyond that a
|
||||||
|
// ring wraps onto itself), or the farthest corner on a bounded no-wrap
|
||||||
|
// plane (beyond that the ring is entirely off-map). A fast ship that
|
||||||
|
// clears the map in one turn therefore shows a single ring; a slow ship
|
||||||
|
// shows all three.
|
||||||
|
|
||||||
|
import type { CirclePrim } from "./world";
|
||||||
|
|
||||||
|
export const REACH_CIRCLE_COLOR = 0x6d8cff;
|
||||||
|
/** High-bit prefix so reach-circle ids never collide with planet
|
||||||
|
* numbers, cargo-route lines, or battle/bombing markers. */
|
||||||
|
export const REACH_CIRCLE_ID_PREFIX = 0xb0000000;
|
||||||
|
const MAX_TURNS = 3;
|
||||||
|
/** Reach rings sit below every interactive primitive so they never win
|
||||||
|
* a click against a planet or ship group. */
|
||||||
|
const REACH_CIRCLE_PRIORITY = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reachBound returns the largest ring radius worth drawing for the map.
|
||||||
|
* On a torus it is half the shorter side (a larger ring overlaps itself);
|
||||||
|
* on a bounded plane it is the distance from the origin to the farthest
|
||||||
|
* corner (a larger ring is wholly off-map).
|
||||||
|
*/
|
||||||
|
export function reachBound(
|
||||||
|
origin: { x: number; y: number },
|
||||||
|
mapWidth: number,
|
||||||
|
mapHeight: number,
|
||||||
|
mode: "torus" | "no-wrap",
|
||||||
|
): number {
|
||||||
|
if (mode === "torus") {
|
||||||
|
return Math.min(mapWidth, mapHeight) / 2;
|
||||||
|
}
|
||||||
|
const dx = Math.max(origin.x, mapWidth - origin.x);
|
||||||
|
const dy = Math.max(origin.y, mapHeight - origin.y);
|
||||||
|
return Math.hypot(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* computeReachCircles produces up to three concentric ring primitives
|
||||||
|
* centred on `origin`, with radii speedPerTurn × {1, 2, 3}. A ring for
|
||||||
|
* turn `t` is included only when the previous ring still fits inside the
|
||||||
|
* map's reach bound, so the count shrinks as the per-turn speed grows.
|
||||||
|
* Returns an empty list when the speed is non-positive.
|
||||||
|
*/
|
||||||
|
export function computeReachCircles(
|
||||||
|
origin: { x: number; y: number },
|
||||||
|
speedPerTurn: number,
|
||||||
|
mapWidth: number,
|
||||||
|
mapHeight: number,
|
||||||
|
mode: "torus" | "no-wrap",
|
||||||
|
): CirclePrim[] {
|
||||||
|
if (speedPerTurn <= 0) return [];
|
||||||
|
const bound = reachBound(origin, mapWidth, mapHeight, mode);
|
||||||
|
const circles: CirclePrim[] = [];
|
||||||
|
for (let turn = 1; turn <= MAX_TURNS; turn++) {
|
||||||
|
// Stop once the previous ring already reached the bound.
|
||||||
|
if (turn > 1 && speedPerTurn * (turn - 1) >= bound) break;
|
||||||
|
circles.push({
|
||||||
|
kind: "circle",
|
||||||
|
id: REACH_CIRCLE_ID_PREFIX + turn,
|
||||||
|
priority: REACH_CIRCLE_PRIORITY,
|
||||||
|
hitSlopPx: 0,
|
||||||
|
x: origin.x,
|
||||||
|
y: origin.y,
|
||||||
|
radius: speedPerTurn * turn,
|
||||||
|
style: {
|
||||||
|
strokeColor: REACH_CIRCLE_COLOR,
|
||||||
|
strokeAlpha: 0.55 - (turn - 1) * 0.12,
|
||||||
|
strokeWidthPx: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return circles;
|
||||||
|
}
|
||||||
@@ -79,6 +79,76 @@ export interface BlockUpgradeCostInput {
|
|||||||
targetTech: number;
|
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 {
|
export interface Core {
|
||||||
/**
|
/**
|
||||||
* signRequest returns the canonical signing input bytes for a v1
|
* signRequest returns the canonical signing input bytes for a v1
|
||||||
@@ -174,6 +244,84 @@ export interface Core {
|
|||||||
* preview.
|
* preview.
|
||||||
*/
|
*/
|
||||||
blockUpgradeCost(input: BlockUpgradeCostInput): number;
|
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>;
|
export type CoreLoader = () => Promise<Core>;
|
||||||
|
|||||||
@@ -10,17 +10,29 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
BlockUpgradeCostInput,
|
BlockUpgradeCostInput,
|
||||||
|
BombingPowerInput,
|
||||||
|
Ceil3Input,
|
||||||
CargoCapacityInput,
|
CargoCapacityInput,
|
||||||
|
CargoForEmptyMassInput,
|
||||||
CarryingMassInput,
|
CarryingMassInput,
|
||||||
Core,
|
Core,
|
||||||
DriveEffectiveInput,
|
DriveEffectiveInput,
|
||||||
|
DriveForSpeedInput,
|
||||||
|
EffectiveAttackInput,
|
||||||
|
EffectiveDefenceInput,
|
||||||
EventSigningFields,
|
EventSigningFields,
|
||||||
FullMassInput,
|
FullMassInput,
|
||||||
|
LoadForFullMassInput,
|
||||||
|
ProduceShipsInTurnInput,
|
||||||
|
ProduceShipsInTurnResult,
|
||||||
RequestSigningFields,
|
RequestSigningFields,
|
||||||
ResponseSigningFields,
|
ResponseSigningFields,
|
||||||
ShipBlocksInput,
|
ShipBlocksInput,
|
||||||
|
ShipBuildCostInput,
|
||||||
|
ShieldsForDefenceInput,
|
||||||
SpeedInput,
|
SpeedInput,
|
||||||
WeaponsBlockInput,
|
WeaponsBlockInput,
|
||||||
|
WeaponsForAttackInput,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +64,17 @@ interface GalaxyCoreBridge {
|
|||||||
cargoCapacity(input: CargoCapacityInput): number;
|
cargoCapacity(input: CargoCapacityInput): number;
|
||||||
carryingMass(input: CarryingMassInput): number;
|
carryingMass(input: CarryingMassInput): number;
|
||||||
blockUpgradeCost(input: BlockUpgradeCostInput): 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 {
|
interface BridgeRequestFields {
|
||||||
@@ -215,6 +238,39 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
|
|||||||
blockUpgradeCost(input: BlockUpgradeCostInput): number {
|
blockUpgradeCost(input: BlockUpgradeCostInput): number {
|
||||||
return bridge.blockUpgradeCost(input);
|
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.
|
fresh.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, setContext } from "svelte";
|
import { onDestroy, onMount, setContext, untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import Header from "$lib/header/header.svelte";
|
import Header from "$lib/header/header.svelte";
|
||||||
@@ -61,6 +61,7 @@ fresh.
|
|||||||
SelectionStore,
|
SelectionStore,
|
||||||
SELECTION_CONTEXT_KEY,
|
SELECTION_CONTEXT_KEY,
|
||||||
} from "$lib/selection.svelte";
|
} from "$lib/selection.svelte";
|
||||||
|
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
|
||||||
import {
|
import {
|
||||||
createRenderedReportSource,
|
createRenderedReportSource,
|
||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
@@ -222,7 +223,28 @@ fresh.
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const sel = selection.selected;
|
const sel = selection.selected;
|
||||||
if (sel === null) return;
|
if (sel === null) return;
|
||||||
activeTab = "inspector";
|
// 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;
|
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.
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 page.getByTestId("inspector-planet-sheet-close").click();
|
||||||
await expect(page.getByTestId("inspector-planet-sheet")).toHaveCount(0);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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-report", "active-view-report", "/report"],
|
||||||
["view-menu-item-mail", "active-view-mail", "/mail"],
|
["view-menu-item-mail", "active-view-mail", "/mail"],
|
||||||
["view-menu-item-battle", "active-view-battle", "/battle"],
|
["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",
|
"view-menu-item-designer-science",
|
||||||
"active-view-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 }> = [
|
const NON_MAP_VIEWS: ReadonlyArray<{ label: string; testid: string }> = [
|
||||||
{ label: "report", testid: "view-menu-item-report" },
|
{ 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: "designer-science", testid: "view-menu-item-designer-science" },
|
||||||
{ label: "mail", testid: "view-menu-item-mail" },
|
{ label: "mail", testid: "view-menu-item-mail" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
// an authenticated session, mocks the gateway with a single local
|
||||||
// planet plus an empty `localShipClass` projection, navigates to
|
// planet plus an empty `localShipClass` projection, navigates to
|
||||||
// the ship-classes table, opens the designer, fills the form, and
|
// the ship-classes table, opens the sidebar calculator, fills the
|
||||||
// asserts that:
|
// design, and asserts that:
|
||||||
//
|
//
|
||||||
// 1. Save adds a `createShipClass` row to the local order draft,
|
// 1. Save adds a `createShipClass` row to the local order draft,
|
||||||
// auto-syncs through `user.games.order`, and the new class
|
// 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,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
testInfo.project.name.startsWith("chromium-mobile"),
|
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" });
|
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(tableHost).toBeVisible();
|
||||||
await expect(page.getByTestId("ship-classes-empty")).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 page.getByTestId("ship-classes-new").click();
|
||||||
await expect(page.getByTestId("active-view-designer-ship-class")).toHaveAttribute(
|
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||||
"data-mode",
|
await expect(calc).toBeVisible();
|
||||||
"new",
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
|
await calc.getByTestId("calculator-name").fill("Drone");
|
||||||
await page.getByTestId("designer-ship-class-input-drive").fill("1");
|
await calc.getByTestId("calculator-block-drive").fill("1");
|
||||||
const save = page.getByTestId("designer-ship-class-save");
|
const create = calc.getByTestId("calculator-create");
|
||||||
await expect(save).toBeEnabled();
|
await expect(create).toBeEnabled();
|
||||||
await save.click();
|
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();
|
await expect(page.getByTestId("ship-classes-table")).toBeVisible();
|
||||||
const row = page.getByTestId("ship-classes-row");
|
const row = page.getByTestId("ship-classes-row");
|
||||||
await expect(row).toHaveAttribute("data-name", "Drone");
|
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");
|
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,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
testInfo.project.name.startsWith("chromium-mobile"),
|
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 mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
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");
|
// Empty name + all-zero blocks: Create is disabled.
|
||||||
await expect(save).toBeDisabled();
|
await expect(create).toBeDisabled();
|
||||||
|
|
||||||
// Empty name surfaces the entity-name error.
|
// Mismatched armament / weapons keeps it disabled (pair rule).
|
||||||
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
|
await calc.getByTestId("calculator-name").fill("Bad");
|
||||||
"name cannot be empty",
|
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.
|
// Filling weapons resolves the pair rule and enables Create.
|
||||||
await page.getByTestId("designer-ship-class-input-name").fill("Bad");
|
await calc.getByTestId("calculator-block-weapons").fill("1");
|
||||||
await page.getByTestId("designer-ship-class-input-armament").fill("1");
|
await expect(create).toBeEnabled();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejected createShipClass keeps the table empty and surfaces the failure", async ({
|
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) => {
|
}, testInfo) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
testInfo.project.name.startsWith("chromium-mobile"),
|
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 mockGateway(page, { createOutcome: "rejected" });
|
||||||
await bootSession(page);
|
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 calc.getByTestId("calculator-name").fill("Drone");
|
||||||
await page.getByTestId("designer-ship-class-input-drive").fill("1");
|
await calc.getByTestId("calculator-block-drive").fill("1");
|
||||||
await page.getByTestId("designer-ship-class-save").click();
|
await calc.getByTestId("calculator-create").click();
|
||||||
|
|
||||||
// Designer's save() calls SvelteKit `goto` to navigate back to
|
// Create stays in the table active view (the calculator is a
|
||||||
// the table. SPA navigation keeps the per-game `OrderDraftStore`
|
// sidebar tool). The per-game OrderDraftStore drives the auto-sync
|
||||||
// alive so the auto-sync round-trip (which flips the status from
|
// round-trip, which flips the status to `rejected`; the order tab
|
||||||
// `submitting` to `rejected`) lands while the table is showing.
|
// carries a `rejected` row and the overlay drops the class once the
|
||||||
// Order tab carries a `rejected` row; the optimistic overlay
|
// engine answers cmdApplied=false.
|
||||||
// drops the class once the engine answers `cmdApplied=false`.
|
|
||||||
await page.getByTestId("sidebar-tab-order").click();
|
await page.getByTestId("sidebar-tab-order").click();
|
||||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
RequestSigningFields,
|
RequestSigningFields,
|
||||||
ResponseSigningFields,
|
ResponseSigningFields,
|
||||||
} from "../src/platform/core/index";
|
} from "../src/platform/core/index";
|
||||||
|
import { makeFakeCore } from "./fake-core";
|
||||||
|
|
||||||
const FIXED_REQUEST_ID = "req-test-1";
|
const FIXED_REQUEST_ID = "req-test-1";
|
||||||
const FIXED_TIMESTAMP = 1_700_000_000_000n;
|
const FIXED_TIMESTAMP = 1_700_000_000_000n;
|
||||||
@@ -204,19 +205,13 @@ function mockCore(opts: MockCoreOptions): Core & {
|
|||||||
verifyEvent: ReturnType<typeof vi.fn>;
|
verifyEvent: ReturnType<typeof vi.fn>;
|
||||||
} {
|
} {
|
||||||
return {
|
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),
|
signRequest: vi.fn(opts.signRequestImpl),
|
||||||
verifyResponse: vi.fn(opts.verifyResponseImpl),
|
verifyResponse: vi.fn(opts.verifyResponseImpl),
|
||||||
verifyEvent: vi.fn(() => true),
|
verifyEvent: vi.fn(() => true),
|
||||||
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
|
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
|
||||||
// `GalaxyClient` does not exercise the Phase 18 calc bridge,
|
|
||||||
// so these stubs only need to satisfy the `Core` interface.
|
|
||||||
driveEffective: () => 0,
|
|
||||||
emptyMass: () => 0,
|
|
||||||
weaponsBlockMass: () => 0,
|
|
||||||
fullMass: () => 0,
|
|
||||||
speed: () => 0,
|
|
||||||
cargoCapacity: () => 0,
|
|
||||||
carryingMass: () => 0,
|
|
||||||
blockUpgradeCost: () => 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,10 +138,6 @@ describe("game-shell header", () => {
|
|||||||
["view-menu-item-report", "/games/g1/report"],
|
["view-menu-item-report", "/games/g1/report"],
|
||||||
["view-menu-item-battle", "/games/g1/battle"],
|
["view-menu-item-battle", "/games/g1/battle"],
|
||||||
["view-menu-item-mail", "/games/g1/mail"],
|
["view-menu-item-mail", "/games/g1/mail"],
|
||||||
[
|
|
||||||
"view-menu-item-designer-ship-class",
|
|
||||||
"/games/g1/designer/ship-class",
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"view-menu-item-designer-science",
|
"view-menu-item-designer-science",
|
||||||
"/games/g1/designer/science",
|
"/games/g1/designer/science",
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
// stub renders the localised view title plus the `coming soon` body
|
// stub renders the localised view title plus the `coming soon` body
|
||||||
// copy and exposes a stable `data-testid` so later phases can replace
|
// copy and exposes a stable `data-testid` so later phases can replace
|
||||||
// the content without renaming the test hook. Phase 17 lit up the
|
// the content without renaming the test hook. Phase 17 lit up the
|
||||||
// ship-classes table and the ship-class designer; Phase 21 lit up
|
// ship-classes table (Phase 30 folded the designer into the sidebar
|
||||||
// the sciences table and the science designer. Their assertions
|
// calculator); Phase 21 lit up the sciences table and the science
|
||||||
// moved to dedicated suites (`table-ship-classes.test.ts`,
|
// designer. Their assertions moved to dedicated suites
|
||||||
// `designer-ship-class.test.ts`, `table-sciences.test.ts`,
|
// (`table-ship-classes.test.ts`, `calculator-tab.test.ts`,
|
||||||
// `designer-science.test.ts`); the `table.svelte` router still falls
|
// `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,
|
// back to the stub for the remaining entities (planets, ship-groups,
|
||||||
// fleets, races) and that fallback is exercised here.
|
// fleets, races) and that fallback is exercised here.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "../src/sync/order-draft.svelte";
|
} from "../src/sync/order-draft.svelte";
|
||||||
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
|
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
|
||||||
import type { Core } from "../src/platform/core/index";
|
import type { Core } from "../src/platform/core/index";
|
||||||
|
import { makeFakeCore } from "./fake-core";
|
||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||||
import type { Cache } from "../src/platform/store/index";
|
import type { Cache } from "../src/platform/store/index";
|
||||||
@@ -107,34 +108,12 @@ function group(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// stubCore mirrors `pkg/calc/ship.go.BlockUpgradeCost` exactly so the
|
// stubCore mirrors `pkg/calc` exactly (via the shared makeFakeCore) so
|
||||||
// preview line shows the same number the WASM bridge would produce.
|
// the preview line shows the same number the WASM bridge would produce.
|
||||||
// The other Core methods are no-ops because the modernize preview
|
// The modernize preview only consults `weaponsBlockMass` (returns null
|
||||||
// only consults `weaponsBlockMass` (returns null when armament is
|
// when armament is zero) and `blockUpgradeCost`.
|
||||||
// zero) and `blockUpgradeCost`.
|
|
||||||
function stubCore(): Core {
|
function stubCore(): Core {
|
||||||
return {
|
return makeFakeCore();
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount(
|
function mount(
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../src/sync/order-draft.svelte";
|
} from "../src/sync/order-draft.svelte";
|
||||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||||
|
import { calculatorLoadRequest } from "../src/lib/calculator/load-request.svelte";
|
||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||||
import type { Cache } from "../src/platform/store/index";
|
import type { Cache } from "../src/platform/store/index";
|
||||||
@@ -188,12 +189,14 @@ describe("ship-classes table", () => {
|
|||||||
expect(names).toEqual(["Battleship", "Cruiser", "Drone"]);
|
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(
|
const ui = mountTable(
|
||||||
makeReport([shipClass({ name: "Drone", drive: 1 })]),
|
makeReport([shipClass({ name: "Drone", drive: 1 })]),
|
||||||
);
|
);
|
||||||
|
const before = calculatorLoadRequest.token;
|
||||||
await fireEvent.dblClick(ui.getByTestId("ship-classes-row"));
|
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 () => {
|
test("delete button adds a removeShipClass to the draft", async () => {
|
||||||
@@ -207,9 +210,11 @@ describe("ship-classes table", () => {
|
|||||||
expect(cmd.name).toBe("Drone");
|
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 ui = mountTable(makeReport([]));
|
||||||
|
const before = calculatorLoadRequest.token;
|
||||||
await fireEvent.click(ui.getByTestId("ship-classes-new"));
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+201
-12
@@ -27,6 +27,21 @@
|
|||||||
// - carryingMass(fields) -> number
|
// - carryingMass(fields) -> number
|
||||||
// - blockUpgradeCost(fields) -> number (Phase 20: modernize cost preview)
|
// - 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
|
// Field objects are plain JS objects with camelCase keys matching the
|
||||||
// TypeScript `Core` interface, and bytes fields are Uint8Array.
|
// TypeScript `Core` interface, and bytes fields are Uint8Array.
|
||||||
// Timestamps are JS Number (Unix milliseconds fit in 53 bits well past
|
// Timestamps are JS Number (Unix milliseconds fit in 53 bits well past
|
||||||
@@ -49,18 +64,29 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
js.Global().Set("galaxyCore", js.ValueOf(map[string]any{
|
js.Global().Set("galaxyCore", js.ValueOf(map[string]any{
|
||||||
"signRequest": js.FuncOf(signRequest),
|
"signRequest": js.FuncOf(signRequest),
|
||||||
"verifyResponse": js.FuncOf(verifyResponse),
|
"verifyResponse": js.FuncOf(verifyResponse),
|
||||||
"verifyEvent": js.FuncOf(verifyEvent),
|
"verifyEvent": js.FuncOf(verifyEvent),
|
||||||
"verifyPayloadHash": js.FuncOf(verifyPayloadHash),
|
"verifyPayloadHash": js.FuncOf(verifyPayloadHash),
|
||||||
"driveEffective": js.FuncOf(driveEffective),
|
"driveEffective": js.FuncOf(driveEffective),
|
||||||
"emptyMass": js.FuncOf(emptyMass),
|
"emptyMass": js.FuncOf(emptyMass),
|
||||||
"weaponsBlockMass": js.FuncOf(weaponsBlockMass),
|
"weaponsBlockMass": js.FuncOf(weaponsBlockMass),
|
||||||
"fullMass": js.FuncOf(fullMass),
|
"fullMass": js.FuncOf(fullMass),
|
||||||
"speed": js.FuncOf(speed),
|
"speed": js.FuncOf(speed),
|
||||||
"cargoCapacity": js.FuncOf(cargoCapacity),
|
"cargoCapacity": js.FuncOf(cargoCapacity),
|
||||||
"carryingMass": js.FuncOf(carryingMass),
|
"carryingMass": js.FuncOf(carryingMass),
|
||||||
"blockUpgradeCost": js.FuncOf(blockUpgradeCost),
|
"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
|
// 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))
|
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
|
// copyBytesFromJS materialises a JS Uint8Array (or any indexable
|
||||||
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
|
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
|
||||||
// because TinyGo's implementation panics on values it does not
|
// because TinyGo's implementation panics on values it does not
|
||||||
|
|||||||
Reference in New Issue
Block a user