Merge pull request 'fix(ui): F8-06 calculator polish — input steps, lock idiom, tech floor, speed-lock fix' (#61) from feature/issue-49-calculator-polish into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Integration / integration (push) Successful in 1m41s
Tests · Go / test (push) Successful in 3m10s
Tests · UI / test (push) Successful in 2m41s

This commit was merged in pull request #61.
This commit is contained in:
2026-05-26 17:23:26 +00:00
12 changed files with 1002 additions and 126 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 // DriveForSpeed returns the drive block that yields targetSpeed for a
// ship whose mass excluding the drive block is restMass, at drive tech // ship whose mass excluding the drive block is restMass, at drive tech
// level driveTech, inverting [Speed] composed with [DriveEffective]. // level driveTech, inverting [Speed] composed with [DriveEffective].
// Speed approaches but never reaches the stripped-hull ceiling // With a positive restMass the speed approaches but never reaches the
// 20*driveTech, so a target at or above the ceiling (or a non-positive // stripped-hull ceiling 20*driveTech, so a target at or above the
// target or tech level) is infeasible. // 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) { func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) {
if driveTech <= 0 || targetSpeed <= 0 {
return 0, false
}
ceiling := 20 * driveTech 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 0, false
} }
return targetSpeed * restMass / (ceiling - targetSpeed), true 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 { if !ok || math.Abs(got-drive) > 1e-9 {
t.Errorf("DriveForSpeed round-trip = %v (ok=%v), want %v", got, ok, drive) 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 { if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok {
t.Error("DriveForSpeed at the speed ceiling should be infeasible") 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) { func TestShieldsForDefence(t *testing.T) {
const shields, shieldsTech, restMass = 5.75, 1.0, 40.0 const shields, shieldsTech, restMass = 5.75, 1.0, 40.0
defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass) defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass)
+86 -29
View File
@@ -24,28 +24,60 @@ in as a per-ship result rather than a separate mode.
1. **Ship Class design area** — five blocks (drive, armament, weapons, 1. **Ship Class design area** — five blocks (drive, armament, weapons,
shields, cargo) and four tech levels (drive, weapons, shields, shields, cargo) and four tech levels (drive, weapons, shields,
cargo). Tech defaults to the player's current tech and shows a lock cargo). Tech defaults to the player's current tech: the cell renders
icon once overridden; clicking it resets to the default. the inherited number with an open padlock; clicking the open lock
activates an input (closed padlock), where the player may type an
override at or above their current tech. Clicking the closed
padlock resets to the default. The padlock slot is always reserved,
so the column width does not shift as the lock state toggles. The
inherited tech value reads through the same 3-decimal `Ceil3`
formatter the report uses, so the column lines up with derived
values. **Every numeric input in the calculator hides the native
spinner and drives stepping through ArrowUp / ArrowDown.** This keeps
the column widths stable, makes the inputs read consistently, and
gives each row a step that matches its purpose. The four ship-class
blocks (drive, weapons, shields, cargo) use a smart step that
respects the engine value rule (`0` or `≥ 1`): ArrowUp from 0 jumps
straight to 1, otherwise +0.1; ArrowDown from 1 collapses to 0,
otherwise 0.1, never producing an invalid value in `(0, 1)`.
Armament steps ±1 (clamped at 0). Tech, planet MAT, custom load,
lock value, and modernization target tech each step by their natural
grain (±0.001 for tech and lock values, ±0.01 for MAT and load).
2. **Calculator area** — derived results: empty/loaded mass, empty/ 2. **Calculator area** — derived results: empty/loaded mass, empty/
loaded speed, attack, defence, bombing (per ship), cargo capacity. loaded speed, attack, defence, bombing (per ship), cargo capacity.
A load toggle (empty / full / custom) sets the cargo load (in cargo A load toggle (empty / full / custom) sets the cargo load (in cargo
units) that the loaded-column results use. At **full** the toggle units) that the loaded-column results use. At **full** the toggle
shows the ship's cargo capacity; a **custom** load over that capacity 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 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. the load is pinned to empty and the toggle is disabled. The bombing
and cargo-capacity rows have no goal-seek lock, but they still
reserve a hidden lock-slot placeholder so the value column stays
vertically aligned with the lockable rows above.
3. **Planet area** — when an own planet is selected on the map, shows 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, its MAT (overridable) and the single-turn build rate (ships per turn,
turns per ship). The realistic multi-turn forecast with CAP/COL turns per ship). The MAT follows the same lock idiom as the tech
supply is planned (see ../ROADMAP.md). cells: the planet number renders with an open padlock, clicking
opens an input with a closed padlock, and the closed padlock resets
to the planet value. The MAT label reads through the same 3-decimal
`Ceil3` formatter, matching the rest of the calculator's label
values. The realistic multi-turn forecast with CAP/COL supply is
planned (see ../ROADMAP.md).
## Locks and goal-seek ## Locks and goal-seek
Two distinct lock semantics share one icon (a closed padlock; it only Two distinct lock semantics share one padlock affordance. Both follow
appears once a value is pinned, click to release): the same idiom — an open padlock (🔓) means *value is inherited /
derived, click to override*; a closed padlock (🔒) means *value is
pinned by the player, click to reset*:
- **Override locks** on inputs that have a default — the four techs and - **Override locks** on inputs that have a default — the four techs and
the planet MAT. Editing one overrides the default; the lock resets it. the planet MAT. By default the cell shows the inherited number plus
Any number may be overridden at once. an open padlock; clicking it switches to an input plus a closed
padlock for typing the override. Closing (clicking the closed
padlock) resets to the default. Any number may be overridden at once.
Tech overrides are floored at the player's current tech on this
turn — a lower value is flagged as invalid. The same floor applies
to the modernization target tech.
- **Goal-seek locks** on derived results. Pinning a result back-solves - **Goal-seek locks** on derived results. Pinning a result back-solves
the single input it claims, which then renders read-only (computed): the single input it claims, which then renders read-only (computed):
@@ -60,12 +92,19 @@ appears once a value is pinned, click to release):
Only **one** result may be locked at a time (the others' lock Only **one** result may be locked at a time (the others' lock
affordances disable with a tooltip). An unreachable target — e.g. a affordances disable with a tooltip). An unreachable target — e.g. a
speed at or above the stripped-hull ceiling `20 × driveTech`, or a speed above the stripped-hull ceiling `20 × driveTech`, or a solved
solved block that fails the value rules — leaves the locked cell in a block that fails the value rules (a DWSC value in the `(0, 1)` gap)
red error state and does not apply. Inverse solving lives in — leaves the locked cell in a red error state and does not apply.
`pkg/calc/solve.go`; the bisection for defence → shields is the only When that happens the claimed block is **not** back-solved into the
non-analytic case. Locking a speed is disabled when the drive block is invalid range; the design preview keeps reading the user's typed
zero (a deliberately immobile ship has no speed to back-solve). values, so the row never silently shows a sub-1 block. 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). With the drive block as the only non-zero mass
the displayed speed equals the ceiling exactly (every positive drive
gives the same speed), so the solver accepts that ceiling target as
a feasible lock and any positive drive solves it.
## Validation and display ## Validation and display
@@ -75,25 +114,43 @@ negative, the five blocks follow the engine value rules
(`pkg/calc/validator.go`, surfaced per-field by (`pkg/calc/validator.go`, surfaced per-field by
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity. `shipClassFieldErrors`), and a custom load may not exceed cargo capacity.
Every displayed number — the derived results and the goal-seek Every displayed number — the derived results, the inherited tech /
back-solved input — is rounded **up** to three decimals through the planet MAT labels, and the goal-seek back-solved input — is rounded
shared `pkg/calc/number.go.Ceil3` (bridged as `core.ceil3`), so a value **up** to three decimals through the shared `pkg/calc/number.go.Ceil3`
is never shown lower than it is (a speed of 5.0003 reads 5.001). The (bridged as `core.ceil3`) and always padded to three decimals so the
engine keeps its own round-to-nearest `util.Fixed*`; `Ceil3` is a column reads the same on integers and fractions alike (a speed of 20
display-only helper that lives in `pkg/calc` so the UI and Go share one shows as `20.000`, of 5.0003 as `5.001`). Labels and inputs use the
implementation. monospace stack from the design tokens (`--font-mono`) with
right-aligned, tabular numerals so values line up vertically across
rows. To match the display rule, every number input also refuses a
fourth decimal as the user types: typing `1.2345` clamps the input to
`1.234` on input. 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 ## Create / load
The name field is a combobox over the player's existing classes. Picking 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 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 new one); Create is disabled while the name is invalid or duplicate
(reusing `lib/util/ship-class-validation.ts`). When a saved class is (reusing `lib/util/ship-class-validation.ts`). Create reuses the existing
loaded, a Delete affordance appears. Create / Delete reuse the existing `createShipClass` order-draft flow, so the optimistic overlay reflects
`createShipClass` / `removeShipClass` order-draft flow, so the optimistic the change immediately. Ship classes are immutable after creation (per
overlay reflects the change immediately. Ship classes are immutable after `game/rules.txt`), so there is no edit — only Create-new. Delete-class
creation (per `game/rules.txt`), so there is no edit — only Create-new lives in the ship-classes table (`lib/active-view/table-ship-classes.svelte`),
and Delete. not the calculator.
Selecting a class from the dropdown loads it **immediately**, the
moment the option is clicked. (Native `change` only fires on blur in
Firefox; switching the load trigger to `input` makes the load
synchronous everywhere, since the `InputEvent.inputType` flags a
datalist replacement as `"insertReplacementText"` in Chromium / WebKit
or `undefined` in Firefox — keyboard typing always carries a typing
`inputType`.) If the live blocks differ from the previously loaded
class (or, when nothing is loaded, from the empty defaults), the
calculator first asks `Discard unsaved changes and load class «…»?`
through a `window.confirm`; declining reverts the name field and
leaves the current blocks untouched.
## Reach circles ## Reach circles
+24 -2
View File
@@ -92,6 +92,18 @@ export interface CalculatorResult {
outputs: CalculatorOutputs | null; outputs: CalculatorOutputs | null;
} }
// isClaimedBlockValid checks that a solver result, before we apply it
// to the resolved blocks, satisfies the same per-field rules the live
// validator enforces on user-typed values (`pkg/calc/validator.go` /
// `lib/util/ship-class-validation`). The four claimable blocks all
// share the DWSC rule, so a single predicate suffices. Used to flag
// a goal-seek target as infeasible when the only block that would
// reach it falls in the (0, 1) gap.
function isClaimedBlockValid(solved: number): boolean {
if (!Number.isFinite(solved)) return false;
return solved === 0 || solved >= 1;
}
function resolveLoad( function resolveLoad(
mode: LoadMode, mode: LoadMode,
customLoad: number, customLoad: number,
@@ -225,8 +237,18 @@ export function computeCalculator(
if (solved === null) { if (solved === null) {
lockFeasible = false; lockFeasible = false;
} else { } else {
blocks[claimed] = solved; // The solver may produce a value that is mathematically
computedInput = claimed; // correct yet rejected by the ship-class value rules —
// most commonly a DWSC block in the (0, 1) gap. Surface
// that as an infeasible lock so the lock input flips
// red and the outputs are suppressed, instead of
// silently showing an invalid design.
if (!isClaimedBlockValid(solved)) {
lockFeasible = false;
} else {
blocks[claimed] = solved;
computedInput = claimed;
}
} }
} }
} }
@@ -1,13 +1,17 @@
<!-- <!--
Reusable "Ship Class design area": the five design blocks (drive, Reusable "Ship Class design area": the five design blocks (drive,
armament, weapons, shields, cargo) plus the four tech levels they are 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 built with. Tech and MAT locks follow the same idiom as goal-seek
lock icon once overridden; clicking the lock resets it. A block claimed locks below the design area — by default the value renders as plain
by an active goal-seek lock renders read-only with its own lock marker. text with an open padlock; clicking it overrides (input + closed
The component is presentational — the parent owns the state and the padlock). Reserved space for the padlock keeps the column width
stable as the lock state toggles. 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. calculator math — so the ship-group upgrade flow can reuse it later.
--> -->
<script lang="ts"> <script lang="ts">
import { tick } from "svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { import {
shipClassFieldErrors, shipClassFieldErrors,
@@ -37,8 +41,17 @@ calculator math — so the ship-group upgrade flow can reuse it later.
resolved: DesignBlocksState; resolved: DesignBlocksState;
techs: TechState; techs: TechState;
techOverridden: Record<TechKey, boolean>; techOverridden: Record<TechKey, boolean>;
// Lower bound for the tech inputs: the player's current tech on
// this turn. A design cannot be built with tech below the player's
// own level, so we surface that as a per-field validation error.
techFloor: TechState;
computedInput?: ClaimedInput | null; computedInput?: ClaimedInput | null;
blocksReadonly?: boolean; blocksReadonly?: boolean;
// Formatter applied to the read-only tech value and to the
// resolved (goal-seek) ship-block value. Same `fmt` as the
// rest of the calculator, passed in so the design area stays
// presentational and the parent owns the rounding policy.
formatNumber: (value: number) => string;
onTechInput: (key: TechKey) => void; onTechInput: (key: TechKey) => void;
onResetTech: (key: TechKey) => void; onResetTech: (key: TechKey) => void;
}; };
@@ -47,8 +60,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
resolved, resolved,
techs = $bindable(), techs = $bindable(),
techOverridden, techOverridden,
techFloor,
computedInput = null, computedInput = null,
blocksReadonly = false, blocksReadonly = false,
formatNumber,
onTechInput, onTechInput,
onResetTech, onResetTech,
}: Props = $props(); }: Props = $props();
@@ -73,21 +88,93 @@ calculator math — so the ship-group upgrade flow can reuse it later.
return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]); return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]);
} }
function techError(key: TechKey): string { function techError(key: TechKey): string {
return techs[key] < 0 ? i18n.t("game.calculator.invalid.tech_value") : ""; const value = techs[key];
if (value < 0) return i18n.t("game.calculator.invalid.tech_value");
if (value < techFloor[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// Smart step on the four ship-class blocks (drive, weapons, shields,
// cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the
// native 0.01 step would produce invalid intermediates like 0.01.
// Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0;
// otherwise 0.1 down to 1, clamped at 0. Armament uses a plain
// integer step (±1, clamped at 0) so it follows the same
// JS-driven idiom and we can hide the native spinner uniformly.
function bumpBlock(value: number, dir: 1 | -1): number {
if (dir === 1) {
if (value < 1) return 1;
return Math.round((value + 0.1) * 10) / 10;
}
if (value <= 1) return 0;
return Math.round((value - 0.1) * 10) / 10;
}
function bumpArmament(value: number, dir: 1 | -1): number {
const next = Math.trunc(value) + dir;
return next < 0 ? 0 : next;
}
function onBlockKey(
event: KeyboardEvent,
key: keyof DesignBlocksState,
smart: boolean,
): void {
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (dir === 0) return;
event.preventDefault();
blocks[key] = smart
? bumpBlock(blocks[key], dir)
: bumpArmament(blocks[key], dir);
}
// Tech / modernization-target inputs all use the same ±0.001 step
// with a per-row floor; lifted into a helper so the parent can
// reuse it (modernization area in `calculator-tab`).
function bumpTech(event: KeyboardEvent, key: TechKey): void {
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (dir === 0) return;
event.preventDefault();
const current = techs[key];
const next = Math.round((current + dir * 0.001) * 1000) / 1000;
const floor = techFloor[key];
techs[key] = next < floor ? floor : next;
}
// Refuse a fourth decimal as typing happens: keeps the calculator
// from ever displaying a >3-decimal fraction the user could not
// have intended (the calculator math is `Ceil3`-rounded for display
// anyway). Pairs with `bind:value` — `apply` overwrites the bound
// state when Svelte's own bind handler has already read the
// over-precise number.
function capDecimals(event: Event, apply: (next: number) => void): void {
const el = event.currentTarget as HTMLInputElement;
const txt = el.value;
const dot = txt.indexOf(".");
if (dot < 0 || txt.length - dot - 1 <= 3) return;
el.value = txt.slice(0, dot + 4);
apply(el.valueAsNumber);
} }
const BLOCK_ROWS: { const BLOCK_ROWS: {
key: keyof DesignBlocksState; key: keyof DesignBlocksState;
label: () => string; label: () => string;
step: string;
tech: TechKey | null; tech: TechKey | null;
smartStep: boolean;
}[] = [ }[] = [
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" }, { key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null }, { key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" }, { key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" }, { key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" }, { key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), tech: "cargo", smartStep: true },
]; ];
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
async function activateTechOverride(key: TechKey): Promise<void> {
onTechInput(key);
await tick();
techInputEls[key]?.focus();
techInputEls[key]?.select();
}
</script> </script>
<div class="design" data-testid="calculator-design-area"> <div class="design" data-testid="calculator-design-area">
@@ -102,46 +189,50 @@ calculator math — so the ship-group upgrade flow can reuse it later.
<span class="label">{row.label()}</span> <span class="label">{row.label()}</span>
{#if isComputed} {#if isComputed}
<input <input
class="ship" class="ship no-spin"
type="number" type="number"
step={row.step} step="any"
readonly readonly
value={resolved[row.key]} value={formatNumber(resolved[row.key])}
data-computed="true" data-computed="true"
data-testid={`calculator-block-${row.key}`} data-testid={`calculator-block-${row.key}`}
title={i18n.t("game.calculator.lock.reset")} title={i18n.t("game.calculator.lock.reset")}
/> />
{:else} {:else}
<input <input
class="ship" class="ship no-spin"
type="number" type="number"
step={row.step} step="any"
min="0" min="0"
bind:value={blocks[row.key]} bind:value={blocks[row.key]}
readonly={blocksReadonly} readonly={blocksReadonly}
aria-invalid={blockError(row.key) !== "" ? "true" : "false"} aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
title={blockError(row.key)} title={blockError(row.key)}
data-testid={`calculator-block-${row.key}`} data-testid={`calculator-block-${row.key}`}
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
oninput={(e) => capDecimals(e, (v) => (blocks[row.key] = v))}
/> />
{/if} {/if}
{#if row.tech !== null} {#if row.tech !== null}
{@const techKey = row.tech} {@const techKey = row.tech}
<span class="tech-cell"> <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]} {#if techOverridden[techKey]}
<input
bind:this={techInputEls[techKey]}
class="tech no-spin"
type="number"
step="any"
min={techFloor[techKey]}
bind:value={techs[techKey]}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
onkeydown={(e) => bumpTech(e, techKey)}
oninput={(e) => capDecimals(e, (v) => (techs[techKey] = v))}
/>
<button <button
type="button" type="button"
class="lock" class="lock active"
title={i18n.t("game.calculator.tech.reset")} title={i18n.t("game.calculator.tech.reset")}
aria-label={i18n.t("game.calculator.tech.reset")} aria-label={i18n.t("game.calculator.tech.reset")}
data-testid={`calculator-tech-reset-${techKey}`} data-testid={`calculator-tech-reset-${techKey}`}
@@ -149,6 +240,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
> >
🔒 🔒
</button> </button>
{:else}
<span
class="tech-val"
data-testid={`calculator-tech-value-${techKey}`}
>
{formatNumber(techs[techKey])}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.tech.override")}
aria-label={i18n.t("game.calculator.tech.override")}
data-testid={`calculator-tech-override-${techKey}`}
onclick={() => void activateTechOverride(techKey)}
>
🔓
</button>
{/if} {/if}
</span> </span>
{:else} {:else}
@@ -182,7 +290,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
font-size: 0.8rem; font-size: 0.8rem;
} }
input { input {
font: inherit; font-family: var(--font-mono);
font-size: 0.8rem; font-size: 0.8rem;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -192,6 +300,19 @@ calculator math — so the ship-group upgrade flow can reuse it later.
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 3px; border-radius: 3px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right;
}
/* Hide native spinners across the design area — the row drives
every numeric edit through ArrowUp/ArrowDown so the column
width stays stable and the inputs read consistently. */
input.no-spin::-webkit-inner-spin-button,
input.no-spin::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.no-spin {
-moz-appearance: textfield;
appearance: textfield;
} }
input[data-computed="true"], input[data-computed="true"],
input[readonly] { input[readonly] {
@@ -206,6 +327,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
align-items: center; align-items: center;
gap: 0.2rem; gap: 0.2rem;
} }
.tech-val {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.2rem 0.35rem;
}
.lock { .lock {
flex: none; flex: none;
padding: 0; padding: 0;
@@ -214,5 +344,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
background: transparent; background: transparent;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:hover {
opacity: 1;
} }
</style> </style>
+4 -1
View File
@@ -364,7 +364,6 @@ const en = {
"game.calculator.name.placeholder": "new class name", "game.calculator.name.placeholder": "new class name",
"game.calculator.name.existing": "your ship classes", "game.calculator.name.existing": "your ship classes",
"game.calculator.action.create": "create", "game.calculator.action.create": "create",
"game.calculator.action.delete": "delete",
"game.calculator.col.ship": "ship", "game.calculator.col.ship": "ship",
"game.calculator.col.tech": "tech", "game.calculator.col.tech": "tech",
"game.calculator.field.drive": "drive", "game.calculator.field.drive": "drive",
@@ -393,7 +392,9 @@ const en = {
"game.calculator.lock.reset": "locked — click to release to the computed value", "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.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.lock.max": "release the locked result first — one result at a time",
"game.calculator.tech.override": "click to override your current tech",
"game.calculator.tech.reset": "overridden — click to reset to your current tech", "game.calculator.tech.reset": "overridden — click to reset to your current tech",
"game.calculator.mat.override": "click to override the planet value",
"game.calculator.mat.reset": "overridden — click to reset to the planet value", "game.calculator.mat.reset": "overridden — click to reset to the planet value",
"game.calculator.modern.current": "current", "game.calculator.modern.current": "current",
"game.calculator.modern.target": "target", "game.calculator.modern.target": "target",
@@ -418,8 +419,10 @@ const en = {
"game.calculator.invalid.all_zero": "at least one value must be nonzero", "game.calculator.invalid.all_zero": "at least one value must be nonzero",
"game.calculator.invalid.negative": "value cannot be negative", "game.calculator.invalid.negative": "value cannot be negative",
"game.calculator.invalid.tech_value": "tech level cannot be negative", "game.calculator.invalid.tech_value": "tech level cannot be negative",
"game.calculator.invalid.tech_below_current": "tech level cannot be below your current tech this turn",
"game.calculator.invalid.load_over_capacity": "load exceeds the ship's cargo capacity", "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.calculator.lock.no_drive": "set a non-zero drive before locking speed",
"game.calculator.confirm_reset_for_load": "Discard unsaved changes and load class «{name}»?",
"game.table.sciences.title": "sciences", "game.table.sciences.title": "sciences",
"game.table.sciences.column.name": "name", "game.table.sciences.column.name": "name",
+4 -1
View File
@@ -365,7 +365,6 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.name.placeholder": "имя нового класса", "game.calculator.name.placeholder": "имя нового класса",
"game.calculator.name.existing": "ваши классы кораблей", "game.calculator.name.existing": "ваши классы кораблей",
"game.calculator.action.create": "создать", "game.calculator.action.create": "создать",
"game.calculator.action.delete": "удалить",
"game.calculator.col.ship": "корабль", "game.calculator.col.ship": "корабль",
"game.calculator.col.tech": "технологии", "game.calculator.col.tech": "технологии",
"game.calculator.field.drive": "двигатель", "game.calculator.field.drive": "двигатель",
@@ -394,7 +393,9 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение", "game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах", "game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз", "game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
"game.calculator.tech.override": "нажмите, чтобы задать свой технологический уровень",
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии", "game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
"game.calculator.mat.override": "нажмите, чтобы задать своё значение MAT",
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты", "game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
"game.calculator.modern.current": "текущий", "game.calculator.modern.current": "текущий",
"game.calculator.modern.target": "целевой", "game.calculator.modern.target": "целевой",
@@ -419,8 +420,10 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым", "game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
"game.calculator.invalid.negative": "значение не может быть отрицательным", "game.calculator.invalid.negative": "значение не может быть отрицательным",
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным", "game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля", "game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость", "game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
"game.calculator.confirm_reset_for_load": "Сбросить несохранённые изменения и загрузить класс «{name}»?",
"game.table.sciences.title": "науки", "game.table.sciences.title": "науки",
"game.table.sciences.column.name": "название", "game.table.sciences.column.name": "название",
+240 -57
View File
@@ -14,7 +14,7 @@ switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes. long-lived planning tool. `ensureGame` resets it when the game changes.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext, tick } from "svelte";
import { appScreen } from "$lib/app-nav.svelte"; import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -204,11 +204,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]), nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
); );
const canCreate = $derived(nameValidation.ok && draft !== undefined); 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). // Per-block modernization upgrade cost (current tech → target tech).
const modernCosts = $derived.by(() => { const modernCosts = $derived.by(() => {
@@ -237,12 +232,35 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
// Display every computed number rounded up to three decimals via the // Display every computed number rounded up to three decimals via the
// shared `Ceil3` bridge, so a value is never shown lower than it is. // shared `Ceil3` bridge, so a value is never shown lower than it is.
// Always three decimals (`1` → `1.000`) for column-aligned readability,
// and without thousands grouping so the same string also embeds in the
// read-only goal-seek `<input type="number">` cell.
function fmt(value: number | null | undefined): string { function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable"); return i18n.t("game.calculator.unavailable");
} }
const rounded = core !== null ? core.ceil3({ value }) : value; const rounded = core !== null ? core.ceil3({ value }) : value;
return rounded.toLocaleString(undefined, { maximumFractionDigits: 3 }); return rounded.toLocaleString(undefined, {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
useGrouping: false,
});
}
// Cap typed precision at three decimal digits. Number inputs use
// `step="any"`, which lets the browser accept arbitrary precision; the
// owner asked us to refuse a fourth decimal as typing happens so the
// calculator never displays a longer-than-three-digit fraction. Pairs
// with `bind:value`: if Svelte's bind handler has already read the
// over-precise number, `apply` overwrites the state with the truncated
// value so the next reactive flush does not undo our truncation.
function capDecimals(event: Event, apply: (next: number) => void): void {
const el = event.currentTarget as HTMLInputElement;
const txt = el.value;
const dot = txt.indexOf(".");
if (dot < 0 || txt.length - dot - 1 <= 3) return;
el.value = txt.slice(0, dot + 4);
apply(el.valueAsNumber);
} }
// The goal-seek back-solved block, shown in its read-only cell, is // The goal-seek back-solved block, shown in its read-only cell, is
@@ -273,6 +291,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "", cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
); );
// Modernization target tech mirrors the design-area floor: a target
// below the player's current tech on this turn is meaningless (no
// upgrade), so flag it the same way.
function targetTechError(key: TechKey): string {
const value = cs.targetTech[key];
if (value < 0) return i18n.t("game.calculator.invalid.negative");
if (value < playerTech[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// Locking a speed back-solves the drive block; with a zero drive the // Locking a speed back-solves the drive block; with a zero drive the
// ship is deliberately immobile, so disallow it. // ship is deliberately immobile, so disallow it.
function lockDisabledReason(output: LockableOutputId): string { function lockDisabledReason(output: LockableOutputId): string {
@@ -291,8 +321,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
function onResetTech(key: TechKey): void { function onResetTech(key: TechKey): void {
cs.techOverridden[key] = false; cs.techOverridden[key] = false;
} }
function onMatInput(): void { const matInputRef: { el?: HTMLInputElement } = {};
async function activateMatOverride(): Promise<void> {
cs.matOverridden = true; cs.matOverridden = true;
await tick();
matInputRef.el?.focus();
matInputRef.el?.select();
} }
function resetMat(): void { function resetMat(): void {
cs.matOverridden = false; cs.matOverridden = false;
@@ -307,6 +341,30 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.lock = null; cs.lock = null;
} }
// Generic ±step keyboard handler for the calculator's free-form
// number inputs (MAT, custom-load, lock value, modernization
// target tech). Pairs with `class="no-spin"` so the native spinner
// is hidden everywhere and the column width is stable; ArrowUp /
// ArrowDown is the only step affordance. The smart 0↔1 jump on
// the ship-class blocks lives in `ship-design-area.svelte` —
// these other inputs accept any non-negative number.
function onStepKey(
event: KeyboardEvent,
current: number,
step: number,
min: number,
apply: (next: number) => void,
): void {
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (dir === 0) return;
event.preventDefault();
// Snap to the same fractional grid as `step` so 0.001 stays
// at three decimals instead of drifting via float math.
const inv = 1 / step;
const next = Math.round((current + dir * step) * inv) / inv;
apply(next < min ? min : next);
}
function loadExisting(clsName: string): void { function loadExisting(clsName: string): void {
const cls = localShipClass.find((c) => c.name === clsName); const cls = localShipClass.find((c) => c.name === clsName);
if (cls === undefined) return; if (cls === undefined) return;
@@ -322,6 +380,78 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.lock = null; cs.lock = null;
} }
// Compare the live blocks to the baseline they were last loaded
// from — or to the empty defaults if no class has been loaded. The
// dropdown selection flow uses this to ask before discarding manual
// edits. Tech overrides are independent of class loading, so they
// don't count as "dirty" here.
function baselineBlocks(): {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
} {
if (cs.loadedExisting !== null) {
const cls = localShipClass.find((c) => c.name === cs.loadedExisting);
if (cls !== undefined) {
return {
drive: cls.drive,
armament: cls.armament,
weapons: cls.weapons,
shields: cls.shields,
cargo: cls.cargo,
};
}
}
return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
}
function isDesignDirty(): boolean {
const base = baselineBlocks();
return (
cs.blocks.drive !== base.drive ||
cs.blocks.armament !== base.armament ||
cs.blocks.weapons !== base.weapons ||
cs.blocks.shields !== base.shields ||
cs.blocks.cargo !== base.cargo
);
}
function tryLoadByName(name: string): void {
const cls = localShipClass.find((c) => c.name === name);
if (cls === undefined) return;
if (cs.loadedExisting === cls.name) return;
if (isDesignDirty()) {
const ok = window.confirm(
i18n.t("game.calculator.confirm_reset_for_load", {
name: cls.name,
}),
);
if (!ok) {
cs.name = cs.loadedExisting ?? "";
return;
}
}
loadExisting(name);
}
// Catch the datalist option click immediately. Native `change` only
// fires on blur in Firefox, which is what made dropdown selection
// look delayed; `input` fires the moment the value is set. Typed
// keystrokes carry an `inputType` ("insertText", "deleteContent…");
// a datalist selection replaces the value in one shot, so its
// `inputType` is undefined (Firefox) or "insertReplacementText"
// (Chromium / WebKit). We treat that as a selection.
function onNameInput(event: Event): void {
const ev = event as InputEvent;
const isSelection =
ev.inputType === undefined ||
ev.inputType === "insertReplacementText";
if (!isSelection) {
cs.loadedExisting = null;
return;
}
tryLoadByName(cs.name);
}
// React to the ship-classes table / bottom-tabs asking to load a // React to the ship-classes table / bottom-tabs asking to load a
// class (or start a fresh design) into the calculator. The layout // class (or start a fresh design) into the calculator. The layout
// flips the sidebar to this tab in parallel. // flips the sidebar to this tab in parallel.
@@ -354,16 +484,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.loadedExisting = created.name; 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({ const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
emptyMass: i18n.t("game.calculator.out.mass"), emptyMass: i18n.t("game.calculator.out.mass"),
loadedMass: i18n.t("game.calculator.out.mass"), loadedMass: i18n.t("game.calculator.out.mass"),
@@ -378,9 +498,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
{#if cs.lock === output} {#if cs.lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}> <span class="cell locked" class:infeasible={!result.lockFeasible}>
<input <input
class="no-spin"
type="number" type="number"
step="0.001" step="any"
bind:value={cs.lockValue} bind:value={cs.lockValue}
onkeydown={(e) =>
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
oninput={(e) => capDecimals(e, (v) => (cs.lockValue = v))}
data-testid={`calculator-locked-${output}`} data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")} title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/> />
@@ -446,8 +570,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
placeholder={i18n.t("game.calculator.name.placeholder")} placeholder={i18n.t("game.calculator.name.placeholder")}
maxlength="30" maxlength="30"
bind:value={cs.name} bind:value={cs.name}
oninput={() => (cs.loadedExisting = null)} oninput={onNameInput}
onchange={() => loadExisting(cs.name)} onchange={() => tryLoadByName(cs.name)}
aria-invalid={nameValidation.ok ? "false" : "true"} aria-invalid={nameValidation.ok ? "false" : "true"}
data-testid="calculator-name" data-testid="calculator-name"
/> />
@@ -469,23 +593,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
</button> </button>
{/if} {/if}
</div> </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 <ShipDesignArea
bind:blocks={cs.blocks} bind:blocks={cs.blocks}
resolved={resolvedCeil} resolved={resolvedCeil}
bind:techs={cs.techValues} bind:techs={cs.techValues}
techOverridden={cs.techOverridden} techOverridden={cs.techOverridden}
techFloor={playerTech}
computedInput={result.computedInput} computedInput={result.computedInput}
formatNumber={fmt}
{onTechInput} {onTechInput}
{onResetTech} {onResetTech}
/> />
@@ -509,10 +625,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
{#if cs.loadMode === "custom"} {#if cs.loadMode === "custom"}
<input <input
type="number" type="number"
step="0.01" step="any"
min="0" min="0"
class="custom-load" class="custom-load no-spin"
bind:value={cs.customLoad} bind:value={cs.customLoad}
onkeydown={(e) =>
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
oninput={(e) => capDecimals(e, (v) => (cs.customLoad = v))}
aria-invalid={customLoadError !== "" ? "true" : "false"} aria-invalid={customLoadError !== "" ? "true" : "false"}
title={customLoadError} title={customLoadError}
data-testid="calculator-custom-load" data-testid="calculator-custom-load"
@@ -560,6 +679,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="val" data-testid="calculator-out-bombing"> <span class="val" data-testid="calculator-out-bombing">
{fmt(result.outputs?.bombing)} {fmt(result.outputs?.bombing)}
</span> </span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span> </span>
<span></span> <span></span>
</div> </div>
@@ -569,6 +689,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="val" data-testid="calculator-out-cargo-capacity"> <span class="val" data-testid="calculator-out-cargo-capacity">
{fmt(result.outputs === null ? null : result.cargoCapacity)} {fmt(result.outputs === null ? null : result.cargoCapacity)}
</span> </span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span> </span>
<span></span> <span></span>
</div> </div>
@@ -589,17 +710,21 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<div class="rrow"> <div class="rrow">
<span class="label">{i18n.t("game.calculator.planet.mat")}</span> <span class="label">{i18n.t("game.calculator.planet.mat")}</span>
<span class="cell"> <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} {#if cs.matOverridden}
<input
bind:this={matInputRef.el}
class="no-spin"
type="number"
step="any"
min="0"
bind:value={cs.matValue}
onkeydown={(e) =>
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
oninput={(e) => capDecimals(e, (v) => (cs.matValue = v))}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
<button <button
type="button" type="button"
class="lock active" class="lock active"
@@ -610,6 +735,23 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
> >
🔒 🔒
</button> </button>
{:else}
<span
class="mat-val"
data-testid="calculator-planet-mat-value"
>
{fmt(cs.matValue)}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.mat.override")}
aria-label={i18n.t("game.calculator.mat.override")}
data-testid="calculator-mat-override"
onclick={() => void activateMatOverride()}
>
🔓
</button>
{/if} {/if}
</span> </span>
<span></span> <span></span>
@@ -638,18 +780,28 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span> <span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div> </div>
{#each modernCosts?.perBlock ?? [] as row (row.key)} {#each modernCosts?.perBlock ?? [] as row (row.key)}
{@const targetError = targetTechError(row.key)}
<div class="rrow"> <div class="rrow">
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span> <span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
<span class="cell"> <span class="cell">
<input <input
class="no-spin"
type="number" type="number"
step="0.001" step="any"
min="0" min={playerTech[row.key]}
bind:value={cs.targetTech[row.key]} bind:value={cs.targetTech[row.key]}
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"} onkeydown={(e) =>
title={cs.targetTech[row.key] < 0 onStepKey(
? i18n.t("game.calculator.invalid.negative") e,
: ""} cs.targetTech[row.key],
0.001,
playerTech[row.key],
(v) => (cs.targetTech[row.key] = v),
)}
oninput={(e) =>
capDecimals(e, (v) => (cs.targetTech[row.key] = v))}
aria-invalid={targetError !== "" ? "true" : "false"}
title={targetError}
data-testid={`calculator-target-${row.key}`} data-testid={`calculator-target-${row.key}`}
/> />
</span> </span>
@@ -718,8 +870,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
.name[aria-invalid="true"] { .name[aria-invalid="true"] {
border-color: var(--color-danger); border-color: var(--color-danger);
} }
.create, .create {
.delete {
font: inherit; font: inherit;
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.25rem 0.55rem; padding: 0.25rem 0.55rem;
@@ -737,10 +888,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.delete {
color: var(--color-danger);
align-self: flex-start;
}
.load { .load {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -766,13 +913,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
} }
.custom-load { .custom-load {
width: 4rem; width: 4rem;
font: inherit; font-family: var(--font-mono);
font-size: 0.8rem; font-size: 0.8rem;
font-variant-numeric: tabular-nums;
padding: 0.15rem 0.3rem; padding: 0.15rem 0.3rem;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 3px; border-radius: 3px;
text-align: right;
} }
.results, .results,
.modern { .modern {
@@ -802,6 +951,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
justify-content: flex-end; justify-content: flex-end;
} }
.cell .val { .cell .val {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-size: 0.85rem; font-size: 0.85rem;
text-align: right; text-align: right;
@@ -809,7 +959,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
.cell input { .cell input {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
font: inherit; font-family: var(--font-mono);
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.15rem 0.3rem; padding: 0.15rem 0.3rem;
background: var(--color-bg); background: var(--color-bg);
@@ -819,6 +969,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right; text-align: right;
} }
/* Hide the native spinner on every calculator number input — the
row drives every numeric edit through ArrowUp / ArrowDown so the
column width is stable and the inputs read consistently with the
ship-block row inside the design area. */
input.no-spin::-webkit-inner-spin-button,
input.no-spin::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.no-spin {
-moz-appearance: textfield;
appearance: textfield;
}
.cell.locked input { .cell.locked input {
color: var(--color-accent); color: var(--color-accent);
border-color: var(--color-accent); border-color: var(--color-accent);
@@ -845,6 +1008,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cursor: not-allowed; cursor: not-allowed;
opacity: 0.2; opacity: 0.2;
} }
.lock-slot {
flex: none;
font-size: 0.7rem;
line-height: 1;
visibility: hidden;
}
.planet { .planet {
border-top: 1px solid var(--color-border-subtle); border-top: 1px solid var(--color-border-subtle);
padding-top: 0.5rem; padding-top: 0.5rem;
@@ -878,6 +1047,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
} }
.planet-stats dd { .planet-stats dd {
margin: 0; margin: 0;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-size: 0.85rem; font-size: 0.85rem;
text-align: right; text-align: right;
@@ -895,8 +1065,21 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cursor: not-allowed; cursor: not-allowed;
} }
.full-capacity { .full-capacity {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-accent); color: var(--color-accent);
} }
/* Plain-text view of the planet MAT (mirrors `.tech-val` in the
design area) so the cell width stays the same whether the value is
the inherited planet number or the player's override. */
.mat-val {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.15rem 0.3rem;
}
</style> </style>
+50
View File
@@ -135,6 +135,56 @@ describe("computeCalculator goal-seek", () => {
expect(result.blocks.drive).toBe(10); expect(result.blocks.drive).toBe(10);
}); });
test("an attack target that back-solves to a (0, 1) weapons block is infeasible", () => {
// weapons = targetAttack / weaponsTech; weaponsTech=1.5, a 0.5
// target → weapons = 0.333…, which fails the DWSC rule (must be
// 0 or ≥ 1). The lock is flagged infeasible so the UI shows the
// red border, and the claimed block is left at its raw value so
// the design preview keeps reading off the user's own design.
const core = makeFakeCore();
const result = computeCalculator(
input({ lock: { output: "attack", value: 0.5 } }),
core,
);
expect(result.lockFeasible).toBe(false);
expect(result.computedInput).toBeNull();
// The claimed block stays at its raw value.
expect(result.blocks.weapons).toBe(0);
});
test("an empty-mass target that back-solves to a (0, 1) cargo block is infeasible", () => {
// emptyMass = drive + shields + cargo; with drive=10 shields=5,
// rest excluding cargo = 15. Target 15.5 → cargo = 0.5, in the
// invalid gap, so the lock is flagged.
const core = makeFakeCore();
const result = computeCalculator(
input({ lock: { output: "emptyMass", value: 15.5 } }),
core,
);
expect(result.lockFeasible).toBe(false);
expect(result.computedInput).toBeNull();
expect(result.blocks.cargo).toBe(5);
});
test("speed lock is feasible at the ceiling when rest mass is zero", () => {
// Regression for the D=1, W=A=S=C=0 case: every block except
// drive is zero, so speed equals 20*driveTech (the ceiling); the
// solver must accept that exact target instead of flagging it
// as unreachable.
const core = makeFakeCore();
const result = computeCalculator(
input({
blocks: { drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0 },
driveTech: 1,
lock: { output: "speedEmpty", value: 20 },
}),
core,
);
expect(result.lockFeasible).toBe(true);
expect(result.computedInput).toBe("drive");
expect(result.outputs?.speedEmpty).toBeCloseTo(20, 9);
});
test("calls the matching solver with the right context", () => { test("calls the matching solver with the right context", () => {
const weaponsForAttack = vi.fn(() => 7); const weaponsForAttack = vi.fn(() => 7);
const core = makeFakeCore({ weaponsForAttack }); const core = makeFakeCore({ weaponsForAttack });
+386
View File
@@ -278,4 +278,390 @@ describe("calculator-tab", () => {
"15.273", "15.273",
); );
}); });
test("tech defaults render as a number with an open-lock affordance", () => {
const ui = mount();
// Default state: no override → number + open lock, no input.
expect(ui.getByTestId("calculator-tech-value-drive")).toHaveTextContent(
"1.2",
);
expect(
ui.getByTestId("calculator-tech-override-drive"),
).toBeInTheDocument();
expect(ui.queryByTestId("calculator-tech-drive")).toBeNull();
});
test("clicking the open tech lock reveals the input + closed lock", async () => {
const ui = mount();
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
// Now an input is rendered and the lock turned closed (reset).
expect(ui.getByTestId("calculator-tech-drive")).toHaveValue(1.2);
expect(ui.getByTestId("calculator-tech-reset-drive")).toBeInTheDocument();
expect(ui.queryByTestId("calculator-tech-value-drive")).toBeNull();
});
test("flags a tech override below the player's current tech", async () => {
const ui = mount();
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
// Player drive is 1.2; setting 0.5 is below the floor.
await fireEvent.input(ui.getByTestId("calculator-tech-drive"), {
target: { value: "0.5" },
});
expect(ui.getByTestId("calculator-tech-drive")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("smart step jumps from 0 to 1 on ArrowUp for ship blocks", async () => {
const ui = mount();
const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement;
drive.focus();
await fireEvent.keyDown(drive, { key: "ArrowUp" });
expect(drive).toHaveValue(1);
await fireEvent.keyDown(drive, { key: "ArrowUp" });
expect(drive).toHaveValue(1.1);
await fireEvent.keyDown(drive, { key: "ArrowDown" });
expect(drive).toHaveValue(1);
await fireEvent.keyDown(drive, { key: "ArrowDown" });
expect(drive).toHaveValue(0);
});
test("regression: speed lock works at the ceiling with all-zero non-drive blocks", async () => {
const ui = mount();
await setBlock(ui, "drive", 1);
// Override drive tech to 1 so the ceiling math is plain.
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
await fireEvent.input(ui.getByTestId("calculator-tech-drive"), {
target: { value: "1" },
});
// With D=1, W=A=S=C=0 the only achievable speed is 20*driveTech=20.
expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent("20");
await fireEvent.click(ui.getByTestId("calculator-lock-speedEmpty"));
const locked = ui.getByTestId("calculator-locked-speedEmpty");
expect(locked).toHaveValue(20);
// The lock is feasible — no infeasible title and no red error class.
expect(locked).not.toHaveAttribute(
"title",
expect.stringMatching(/cannot be reached/i),
);
});
test("planet MAT defaults to a value + open lock and opens an input on click", async () => {
const selection = new SelectionStore();
selection.selectPlanet(17);
const ui = mount({
report: makeReport({ planets: [LOCAL_PLANET] }),
selection,
});
// Initial state: the MAT shows the planet's value via the number cell
// and an open lock; no input until the override is activated.
expect(
ui.getByTestId("calculator-planet-mat-value"),
).toHaveTextContent("100");
expect(
ui.getByTestId("calculator-mat-override"),
).toBeInTheDocument();
expect(ui.queryByTestId("calculator-planet-mat")).toBeNull();
await fireEvent.click(ui.getByTestId("calculator-mat-override"));
expect(ui.getByTestId("calculator-planet-mat")).toHaveValue(100);
expect(ui.getByTestId("calculator-mat-reset")).toBeInTheDocument();
});
test("flags a modernization target below the player's current tech", async () => {
const ui = mount();
await fireEvent.click(ui.getByTestId("calculator-mode-modernization"));
// Player drive is 1.2; the target is seeded with the same value.
await fireEvent.input(ui.getByTestId("calculator-target-drive"), {
target: { value: "0.5" },
});
expect(ui.getByTestId("calculator-target-drive")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("armament Arrow keys step the integer block by ±1 (clamped at 0)", async () => {
const ui = mount();
const armament = ui.getByTestId(
"calculator-block-armament",
) as HTMLInputElement;
armament.focus();
await fireEvent.keyDown(armament, { key: "ArrowUp" });
expect(armament).toHaveValue(1);
await fireEvent.keyDown(armament, { key: "ArrowUp" });
expect(armament).toHaveValue(2);
await fireEvent.keyDown(armament, { key: "ArrowDown" });
expect(armament).toHaveValue(1);
await fireEvent.keyDown(armament, { key: "ArrowDown" });
expect(armament).toHaveValue(0);
// Clamped at zero — another ArrowDown is a no-op.
await fireEvent.keyDown(armament, { key: "ArrowDown" });
expect(armament).toHaveValue(0);
});
test("renders unoverridden tech as a 3-decimal label (matches the report)", () => {
// Player drive tech 1.2 → "1.200" via the shared ceil3 formatter,
// always padded to three decimals (calculator labels are column-
// aligned with the report).
const ui = mount();
const tech = ui.getByTestId("calculator-tech-value-drive");
expect((tech.textContent ?? "").trim()).toBe("1.200");
});
test("planet MAT label renders through the 3-decimal formatter", () => {
const selection = new SelectionStore();
selection.selectPlanet(17);
const ui = mount({
report: makeReport({ planets: [LOCAL_PLANET] }),
selection,
});
// Planet MAT is 100 → "100.000" through the shared formatter; the
// label is monospaced + right-aligned via the existing `.mat-val`
// rule. Integer MAT pads to three decimals like every other label.
const mat = ui.getByTestId("calculator-planet-mat-value");
expect((mat.textContent ?? "").trim()).toBe("100.000");
});
test("derived results pad to three decimals (integer empty mass)", async () => {
// Integer-valued outputs read with the same trailing zeros as
// fractional ones — column-aligned tabular display.
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
const mass = ui.getByTestId("calculator-out-emptyMass");
expect((mass.textContent ?? "").trim()).toBe("20.000");
});
test("number inputs refuse a fourth decimal as the user types", async () => {
const selection = new SelectionStore();
selection.selectPlanet(17);
const ui = mount({
report: makeReport({ planets: [LOCAL_PLANET] }),
selection,
});
// MAT input: typed "12.3456" must clamp to "12.345" on input.
await fireEvent.click(ui.getByTestId("calculator-mat-override"));
const mat = ui.getByTestId("calculator-planet-mat") as HTMLInputElement;
await fireEvent.input(mat, { target: { value: "12.3456" } });
expect(mat.value).toBe("12.345");
expect(mat.valueAsNumber).toBeCloseTo(12.345, 9);
// Custom-load input on a ship with a non-zero cargo: typed
// "1.2345" must clamp to "1.234".
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
await fireEvent.click(ui.getByTestId("calculator-load-custom"));
const load = ui.getByTestId("calculator-custom-load") as HTMLInputElement;
await fireEvent.input(load, { target: { value: "1.2345" } });
expect(load.value).toBe("1.234");
});
test("tech and target-tech inputs cap at three decimals", async () => {
const ui = mount();
// Tech override input.
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
const tech = ui.getByTestId("calculator-tech-drive") as HTMLInputElement;
await fireEvent.input(tech, { target: { value: "2.9999" } });
expect(tech.value).toBe("2.999");
// Modernization target tech input.
await fireEvent.click(ui.getByTestId("calculator-mode-modernization"));
const target = ui.getByTestId(
"calculator-target-drive",
) as HTMLInputElement;
await fireEvent.input(target, { target: { value: "3.1416" } });
expect(target.value).toBe("3.141");
});
test("lock value input caps at three decimals", 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-attack"));
const lock = ui.getByTestId(
"calculator-locked-attack",
) as HTMLInputElement;
await fireEvent.input(lock, { target: { value: "0.1234" } });
expect(lock.value).toBe("0.123");
});
test("ship-block input caps at three decimals", async () => {
const ui = mount();
const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement;
await fireEvent.input(drive, { target: { value: "1.2345" } });
expect(drive.value).toBe("1.234");
});
test("lock spinner step is replaced by ArrowUp/ArrowDown (±0.001)", 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-attack"));
const locked = ui.getByTestId(
"calculator-locked-attack",
) as HTMLInputElement;
// Lock value is seeded from outputs.attack (0 with no weapons).
const start = Number(locked.value);
locked.focus();
await fireEvent.keyDown(locked, { key: "ArrowUp" });
expect(Number(locked.value)).toBeCloseTo(start + 0.001, 9);
await fireEvent.keyDown(locked, { key: "ArrowDown" });
expect(Number(locked.value)).toBeCloseTo(start, 9);
});
test("flags the lock as infeasible when the back-solved block falls in (0, 1)", async () => {
// attack lock → weapons = targetAttack / weaponsTech. weaponsTech
// is 1.5; a target of 0.5 would force weapons = 0.333… which
// fails the DWSC rule (must be 0 or ≥ 1).
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: "0.5" },
});
const locked = ui.getByTestId("calculator-locked-attack");
expect(locked).toHaveAttribute(
"title",
expect.stringMatching(/cannot be reached/i),
);
// The claimed block is not back-solved into the invalid (0, 1)
// range — the weapons input keeps the user's typed value (5).
expect(ui.getByTestId("calculator-block-weapons")).toHaveValue(5);
});
test("dropdown selection loads the class immediately (no blur needed)", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
// A datalist option click sets the whole value at once — Firefox
// reports no `inputType`, Chromium reports "insertReplacementText".
// Simulate the latter; the calculator should load before any
// `change` event.
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
expect(ui.getByTestId("calculator-block-shields")).toHaveValue(2);
});
test("dropdown selection asks before discarding manual edits", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
// The user has hand-edited the design.
await setBlock(ui, "drive", 7);
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
expect(confirm).toHaveBeenCalledTimes(1);
// The user said no — the manual edits stay.
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(7);
// The name field is reverted to the previously loaded class (or
// empty), so the field does not pretend the load happened.
expect(ui.getByTestId("calculator-name")).toHaveValue("");
confirm.mockReturnValue(true);
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
// Confirmed — the class is now loaded.
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
confirm.mockRestore();
});
test("dropdown selection loads silently when the design is clean", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
const confirm = vi.spyOn(window, "confirm");
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
expect(confirm).not.toHaveBeenCalled();
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
confirm.mockRestore();
});
test("does not render a delete-class button after loading a class", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
// The loaded class state used to render a `delete <name>` button;
// the calculator no longer owns delete-class — issue #53 will.
expect(ui.queryByTestId("calculator-delete")).toBeNull();
});
test("bombing and cargo-capacity rows reserve the lock slot for column alignment", () => {
const ui = mount();
for (const id of ["calculator-out-bombing", "calculator-out-cargo-capacity"]) {
const cell = ui.getByTestId(id).parentElement;
expect(cell).not.toBeNull();
// A hidden placeholder occupies the same width as the lock button
// on the mass/speed/attack/defence rows, so the value column does
// not drift right on the rows without a lock.
expect(cell!.querySelector(".lock-slot")).not.toBeNull();
}
});
}); });
@@ -275,6 +275,9 @@ test("calculator draws reach circles for the selected planet", async ({
await calc.getByTestId("calculator-block-drive").fill("10"); await calc.getByTestId("calculator-block-drive").fill("10");
await calc.getByTestId("calculator-block-shields").fill("5"); await calc.getByTestId("calculator-block-shields").fill("5");
await calc.getByTestId("calculator-block-cargo").fill("5"); await calc.getByTestId("calculator-block-cargo").fill("5");
// Tech defaults render as a number + open lock; click to reveal the
// input before typing an override (the F8-06 unified lock idiom).
await calc.getByTestId("calculator-tech-override-drive").click();
await calc.getByTestId("calculator-tech-drive").fill("1.2"); await calc.getByTestId("calculator-tech-drive").fill("1.2");
await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0); await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
+5 -2
View File
@@ -94,10 +94,13 @@ export function makeFakeCore(overrides: Partial<Core> = {}): Core {
weaponsForAttack: ({ targetAttack, weaponsTech }) => weaponsForAttack: ({ targetAttack, weaponsTech }) =>
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech, weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => { driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
if (driveTech <= 0 || targetSpeed <= 0) return null;
const ceiling = 20 * driveTech; const ceiling = 20 * driveTech;
if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) { if (restMass <= 0) {
return null; if (targetSpeed !== ceiling) return null;
return 1;
} }
if (targetSpeed >= ceiling) return null;
return (targetSpeed * restMass) / (ceiling - targetSpeed); return (targetSpeed * restMass) / (ceiling - targetSpeed);
}, },
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => { shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {