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
@@ -1,13 +1,17 @@
<!--
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
built with. Tech and MAT locks follow the same idiom as goal-seek
locks below the design area — by default the value renders as plain
text with an open padlock; clicking it overrides (input + closed
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.
-->
<script lang="ts">
import { tick } from "svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
shipClassFieldErrors,
@@ -37,6 +41,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
resolved: DesignBlocksState;
techs: TechState;
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;
blocksReadonly?: boolean;
onTechInput: (key: TechKey) => void;
@@ -47,6 +55,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
resolved,
techs = $bindable(),
techOverridden,
techFloor,
computedInput = null,
blocksReadonly = false,
onTechInput,
@@ -73,7 +82,38 @@ calculator math — so the ship-group upgrade flow can reuse it later.
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 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 keeps native step 1.
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 onBlockKey(
event: KeyboardEvent,
key: keyof DesignBlocksState,
): void {
if (event.key === "ArrowUp") {
event.preventDefault();
blocks[key] = bumpBlock(blocks[key], 1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
blocks[key] = bumpBlock(blocks[key], -1);
}
}
const BLOCK_ROWS: {
@@ -81,13 +121,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
label: () => string;
step: string;
tech: TechKey | null;
smartStep: boolean;
}[] = [
{ 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" },
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.1", tech: "drive", smartStep: true },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null, smartStep: false },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.1", tech: "weapons", smartStep: true },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.1", tech: "shields", smartStep: true },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.1", 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>
<div class="design" data-testid="calculator-design-area">
@@ -103,6 +153,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
{#if isComputed}
<input
class="ship"
class:smart-step={row.smartStep}
type="number"
step={row.step}
readonly
@@ -114,6 +165,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
{:else}
<input
class="ship"
class:smart-step={row.smartStep}
type="number"
step={row.step}
min="0"
@@ -122,26 +174,29 @@ calculator math — so the ship-group upgrade flow can reuse it later.
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
title={blockError(row.key)}
data-testid={`calculator-block-${row.key}`}
onkeydown={row.smartStep
? (e) => onBlockKey(e, row.key)
: null}
/>
{/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]}
<input
bind:this={techInputEls[techKey]}
class="tech"
type="number"
step="0.001"
min={techFloor[techKey]}
bind:value={techs[techKey]}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
/>
<button
type="button"
class="lock"
class="lock active"
title={i18n.t("game.calculator.tech.reset")}
aria-label={i18n.t("game.calculator.tech.reset")}
data-testid={`calculator-tech-reset-${techKey}`}
@@ -149,6 +204,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
>
🔒
</button>
{:else}
<span
class="tech-val"
data-testid={`calculator-tech-value-${techKey}`}
>
{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}
</span>
{:else}
@@ -193,6 +265,18 @@ calculator math — so the ship-group upgrade flow can reuse it later.
border-radius: 3px;
font-variant-numeric: tabular-nums;
}
/* Drive/weapons/shields/cargo use the JS-driven smart step (0→1 jump
then 0.1 increments) for keyboard arrows; hide the native spinner
on those inputs so it cannot produce invalid 0.01 intermediates. */
input.smart-step::-webkit-inner-spin-button,
input.smart-step::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.smart-step {
-moz-appearance: textfield;
appearance: textfield;
}
input[data-computed="true"],
input[readonly] {
color: var(--color-accent);
@@ -206,6 +290,14 @@ calculator math — so the ship-group upgrade flow can reuse it later.
align-items: center;
gap: 0.2rem;
}
.tech-val {
flex: 1;
min-width: 0;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.2rem 0.35rem;
}
.lock {
flex: none;
padding: 0;
@@ -214,5 +306,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:hover {
opacity: 1;
}
</style>
+3
View File
@@ -393,7 +393,9 @@ const en = {
"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.override": "click to override 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.modern.current": "current",
"game.calculator.modern.target": "target",
@@ -418,6 +420,7 @@ const en = {
"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.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.lock.no_drive": "set a non-zero drive before locking speed",
+3
View File
@@ -394,7 +394,9 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
"game.calculator.tech.override": "нажмите, чтобы задать свой технологический уровень",
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
"game.calculator.mat.override": "нажмите, чтобы задать своё значение MAT",
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
"game.calculator.modern.current": "текущий",
"game.calculator.modern.target": "целевой",
@@ -419,6 +421,7 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
"game.calculator.invalid.negative": "значение не может быть отрицательным",
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
@@ -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.
-->
<script lang="ts">
import { getContext } from "svelte";
import { getContext, tick } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -273,6 +273,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
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
// ship is deliberately immobile, so disallow it.
function lockDisabledReason(output: LockableOutputId): string {
@@ -291,8 +303,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
function onResetTech(key: TechKey): void {
cs.techOverridden[key] = false;
}
function onMatInput(): void {
const matInputRef: { el?: HTMLInputElement } = {};
async function activateMatOverride(): Promise<void> {
cs.matOverridden = true;
await tick();
matInputRef.el?.focus();
matInputRef.el?.select();
}
function resetMat(): void {
cs.matOverridden = false;
@@ -485,6 +501,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
resolved={resolvedCeil}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
techFloor={playerTech}
computedInput={result.computedInput}
{onTechInput}
{onResetTech}
@@ -589,17 +606,17 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<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}
<input
bind:this={matInputRef.el}
type="number"
step="0.01"
min="0"
bind:value={cs.matValue}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
<button
type="button"
class="lock active"
@@ -610,6 +627,23 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
>
🔒
</button>
{:else}
<span
class="mat-val"
data-testid="calculator-planet-mat-value"
>
{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}
</span>
<span></span>
@@ -638,18 +672,17 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div>
{#each modernCosts?.perBlock ?? [] as row (row.key)}
{@const targetError = targetTechError(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"
min={playerTech[row.key]}
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")
: ""}
aria-invalid={targetError !== "" ? "true" : "false"}
title={targetError}
data-testid={`calculator-target-${row.key}`}
/>
</span>
@@ -899,4 +932,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
font-size: 0.8rem;
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-size: 0.85rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.15rem 0.3rem;
}
</style>
+19
View File
@@ -135,6 +135,25 @@ describe("computeCalculator goal-seek", () => {
expect(result.blocks.drive).toBe(10);
});
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", () => {
const weaponsForAttack = vi.fn(() => 7);
const core = makeFakeCore({ weaponsForAttack });
+102
View File
@@ -278,4 +278,106 @@ describe("calculator-tab", () => {
"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",
);
});
});
@@ -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-shields").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 expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
+5 -2
View File
@@ -94,10 +94,13 @@ export function makeFakeCore(overrides: Partial<Core> = {}): Core {
weaponsForAttack: ({ targetAttack, weaponsTech }) =>
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
if (driveTech <= 0 || targetSpeed <= 0) return null;
const ceiling = 20 * driveTech;
if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) {
return null;
if (restMass <= 0) {
if (targetSpeed !== ceiling) return null;
return 1;
}
if (targetSpeed >= ceiling) return null;
return (targetSpeed * restMass) / (ceiling - targetSpeed);
},
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {