fix(ui): calculator polish — smart input steps, unified tech/MAT lock idiom, tech floor, speed-lock ceiling fix
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · UI / test (pull_request) Successful in 2m32s

- pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only
  case (every positive drive solves it), so locking the displayed
  speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible".
- ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven
  smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide
  the native spinner so it cannot produce invalid (0, 1) values;
  armament keeps its native step 1.
- Tech and planet MAT cells follow the same lock idiom as goal-seek
  locks: open padlock (🔓) over the inherited value → click to open
  an input with a closed padlock (🔒). The padlock slot is always
  reserved, so the column width is stable.
- Tech overrides (design area and modernization target) are floored
  at the player's current tech on this turn — a lower value is
  flagged as invalid.
This commit is contained in:
Ilia Denisov
2026-05-26 14:30:43 +02:00
parent 793b709d8f
commit e9b904332e
11 changed files with 384 additions and 55 deletions
+17 -4
View File
@@ -22,12 +22,25 @@ func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) {
// 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.
// With a positive restMass the speed approaches but never reaches the
// stripped-hull ceiling 20*driveTech, so a target at or above the
// ceiling is infeasible. With restMass==0 the drive block carries no
// other mass: every positive drive yields exactly the ceiling speed, so
// the ceiling target is the only feasible one and any positive drive
// (canonically 1) solves it. Non-positive targetSpeed or driveTech are
// always infeasible.
func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) {
if driveTech <= 0 || targetSpeed <= 0 {
return 0, false
}
ceiling := 20 * driveTech
if driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling {
if restMass <= 0 {
if targetSpeed != ceiling {
return 0, false
}
return 1, true
}
if targetSpeed >= ceiling {
return 0, false
}
return targetSpeed * restMass / (ceiling - targetSpeed), true
+19 -1
View File
@@ -24,12 +24,30 @@ func TestDriveForSpeed(t *testing.T) {
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.
// With a positive restMass speed can never reach 20*driveTech.
if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok {
t.Error("DriveForSpeed at the speed ceiling should be infeasible")
}
}
func TestDriveForSpeedZeroRest(t *testing.T) {
// With restMass==0 the only achievable speed is the stripped-hull
// ceiling 20*driveTech; any positive drive reaches it. Off-ceiling
// targets are infeasible.
const driveTech = 1.5
ceiling := 20 * driveTech
got, ok := calc.DriveForSpeed(ceiling, driveTech, 0)
if !ok || got <= 0 {
t.Errorf("DriveForSpeed(ceiling, _, 0) = %v (ok=%v), want positive", got, ok)
}
if _, ok := calc.DriveForSpeed(ceiling/2, driveTech, 0); ok {
t.Error("DriveForSpeed(below ceiling, _, 0) should be infeasible")
}
if _, ok := calc.DriveForSpeed(ceiling+1, driveTech, 0); ok {
t.Error("DriveForSpeed(above ceiling, _, 0) 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)