fix(ui): F8-06 calculator polish — input steps, lock idiom, tech floor, speed-lock fix #61
+42
-18
@@ -30,12 +30,19 @@ in as a per-ship result rather than a separate mode.
|
||||
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
|
||||
four ship-class blocks (drive, weapons, shields, cargo) use a smart
|
||||
keyboard 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)`. The native spinner is hidden on these inputs (it would
|
||||
produce invalid intermediates); armament keeps its native step 1.
|
||||
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/
|
||||
loaded speed, attack, defence, bombing (per ship), cargo capacity.
|
||||
A load toggle (empty / full / custom) sets the cargo load (in cargo
|
||||
@@ -48,8 +55,10 @@ in as a per-ship result rather than a separate mode.
|
||||
turns per ship). The MAT follows the same lock idiom as the tech
|
||||
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 realistic multi-turn forecast with CAP/COL
|
||||
supply is planned (see ../ROADMAP.md).
|
||||
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
|
||||
|
||||
@@ -80,16 +89,19 @@ pinned by the player, click to reset*:
|
||||
|
||||
Only **one** result may be locked at a time (the others' lock
|
||||
affordances disable with a tooltip). An unreachable target — e.g. a
|
||||
speed above the stripped-hull ceiling `20 × driveTech`, or a
|
||||
solved block that fails the value rules — leaves the locked cell in a
|
||||
red error state and does not apply. Inverse solving lives in
|
||||
`pkg/calc/solve.go`; the bisection for defence → shields is the only
|
||||
non-analytic case. Locking a speed is disabled when the drive block is
|
||||
zero (a deliberately immobile ship has no speed to back-solve). 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.
|
||||
speed above the stripped-hull ceiling `20 × driveTech`, or a solved
|
||||
block that fails the value rules (a DWSC value in the `(0, 1)` gap)
|
||||
— leaves the locked cell in a red error state and does not apply.
|
||||
When that happens the claimed block is **not** back-solved into the
|
||||
invalid range; the design preview keeps reading the user's typed
|
||||
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
|
||||
|
||||
@@ -119,6 +131,18 @@ overlay reflects the change immediately. Ship classes are immutable after
|
||||
creation (per `game/rules.txt`), so there is no edit — only Create-new
|
||||
and Delete.
|
||||
|
||||
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
|
||||
|
||||
When an own planet is selected in calculator mode, the calculator
|
||||
|
||||
@@ -92,6 +92,18 @@ export interface CalculatorResult {
|
||||
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(
|
||||
mode: LoadMode,
|
||||
customLoad: number,
|
||||
@@ -225,8 +237,18 @@ export function computeCalculator(
|
||||
if (solved === null) {
|
||||
lockFeasible = false;
|
||||
} else {
|
||||
blocks[claimed] = solved;
|
||||
computedInput = claimed;
|
||||
// The solver may produce a value that is mathematically
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
techFloor: TechState;
|
||||
computedInput?: ClaimedInput | null;
|
||||
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;
|
||||
onResetTech: (key: TechKey) => void;
|
||||
};
|
||||
@@ -58,6 +63,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
techFloor,
|
||||
computedInput = null,
|
||||
blocksReadonly = false,
|
||||
formatNumber,
|
||||
onTechInput,
|
||||
onResetTech,
|
||||
}: Props = $props();
|
||||
@@ -94,7 +100,9 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -103,31 +111,46 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
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 {
|
||||
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 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;
|
||||
}
|
||||
|
||||
const BLOCK_ROWS: {
|
||||
key: keyof DesignBlocksState;
|
||||
label: () => string;
|
||||
step: string;
|
||||
tech: TechKey | null;
|
||||
smartStep: boolean;
|
||||
}[] = [
|
||||
{ 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 },
|
||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true },
|
||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false },
|
||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true },
|
||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true },
|
||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), tech: "cargo", smartStep: true },
|
||||
];
|
||||
|
||||
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
|
||||
@@ -152,31 +175,27 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
<span class="label">{row.label()}</span>
|
||||
{#if isComputed}
|
||||
<input
|
||||
class="ship"
|
||||
class:smart-step={row.smartStep}
|
||||
class="ship no-spin"
|
||||
type="number"
|
||||
step={row.step}
|
||||
step="any"
|
||||
readonly
|
||||
value={resolved[row.key]}
|
||||
value={formatNumber(resolved[row.key])}
|
||||
data-computed="true"
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
title={i18n.t("game.calculator.lock.reset")}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="ship"
|
||||
class:smart-step={row.smartStep}
|
||||
class="ship no-spin"
|
||||
type="number"
|
||||
step={row.step}
|
||||
step="any"
|
||||
min="0"
|
||||
bind:value={blocks[row.key]}
|
||||
readonly={blocksReadonly}
|
||||
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
||||
title={blockError(row.key)}
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
onkeydown={row.smartStep
|
||||
? (e) => onBlockKey(e, row.key)
|
||||
: null}
|
||||
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
|
||||
/>
|
||||
{/if}
|
||||
{#if row.tech !== null}
|
||||
@@ -185,14 +204,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
{#if techOverridden[techKey]}
|
||||
<input
|
||||
bind:this={techInputEls[techKey]}
|
||||
class="tech"
|
||||
class="tech no-spin"
|
||||
type="number"
|
||||
step="0.001"
|
||||
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)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -209,7 +229,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
class="tech-val"
|
||||
data-testid={`calculator-tech-value-${techKey}`}
|
||||
>
|
||||
{techs[techKey]}
|
||||
{formatNumber(techs[techKey])}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -265,15 +285,15 @@ 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 {
|
||||
/* 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.smart-step {
|
||||
input.no-spin {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
@@ -423,6 +423,7 @@ const en = {
|
||||
"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",
|
||||
"game.calculator.confirm_reset_for_load": "Discard unsaved changes and load class «{name}»?",
|
||||
|
||||
"game.table.sciences.title": "sciences",
|
||||
"game.table.sciences.column.name": "name",
|
||||
|
||||
@@ -424,6 +424,7 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
|
||||
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
||||
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
||||
"game.calculator.confirm_reset_for_load": "Сбросить несохранённые изменения и загрузить класс «{name}»?",
|
||||
|
||||
"game.table.sciences.title": "науки",
|
||||
"game.table.sciences.column.name": "название",
|
||||
|
||||
@@ -323,6 +323,30 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
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 {
|
||||
const cls = localShipClass.find((c) => c.name === clsName);
|
||||
if (cls === undefined) return;
|
||||
@@ -338,6 +362,78 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
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
|
||||
// class (or start a fresh design) into the calculator. The layout
|
||||
// flips the sidebar to this tab in parallel.
|
||||
@@ -394,9 +490,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
{#if cs.lock === output}
|
||||
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
||||
<input
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="0.001"
|
||||
step="any"
|
||||
bind:value={cs.lockValue}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
|
||||
data-testid={`calculator-locked-${output}`}
|
||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
||||
/>
|
||||
@@ -462,8 +561,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
placeholder={i18n.t("game.calculator.name.placeholder")}
|
||||
maxlength="30"
|
||||
bind:value={cs.name}
|
||||
oninput={() => (cs.loadedExisting = null)}
|
||||
onchange={() => loadExisting(cs.name)}
|
||||
oninput={onNameInput}
|
||||
onchange={() => tryLoadByName(cs.name)}
|
||||
aria-invalid={nameValidation.ok ? "false" : "true"}
|
||||
data-testid="calculator-name"
|
||||
/>
|
||||
@@ -503,6 +602,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
techOverridden={cs.techOverridden}
|
||||
techFloor={playerTech}
|
||||
computedInput={result.computedInput}
|
||||
formatNumber={fmt}
|
||||
{onTechInput}
|
||||
{onResetTech}
|
||||
/>
|
||||
@@ -526,10 +626,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
{#if cs.loadMode === "custom"}
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
min="0"
|
||||
class="custom-load"
|
||||
class="custom-load no-spin"
|
||||
bind:value={cs.customLoad}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
|
||||
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
||||
title={customLoadError}
|
||||
data-testid="calculator-custom-load"
|
||||
@@ -609,10 +711,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
{#if cs.matOverridden}
|
||||
<input
|
||||
bind:this={matInputRef.el}
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
min="0"
|
||||
bind:value={cs.matValue}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
|
||||
aria-invalid={matError !== "" ? "true" : "false"}
|
||||
title={matError}
|
||||
data-testid="calculator-planet-mat"
|
||||
@@ -632,7 +737,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
class="mat-val"
|
||||
data-testid="calculator-planet-mat-value"
|
||||
>
|
||||
{cs.matValue}
|
||||
{fmt(cs.matValue)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -677,10 +782,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="0.001"
|
||||
step="any"
|
||||
min={playerTech[row.key]}
|
||||
bind:value={cs.targetTech[row.key]}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(
|
||||
e,
|
||||
cs.targetTech[row.key],
|
||||
0.001,
|
||||
playerTech[row.key],
|
||||
(v) => (cs.targetTech[row.key] = v),
|
||||
)}
|
||||
aria-invalid={targetError !== "" ? "true" : "false"}
|
||||
title={targetError}
|
||||
data-testid={`calculator-target-${row.key}`}
|
||||
@@ -852,6 +966,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
font-variant-numeric: tabular-nums;
|
||||
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 {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
|
||||
@@ -135,6 +135,37 @@ describe("computeCalculator goal-seek", () => {
|
||||
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
|
||||
|
||||
@@ -380,4 +380,183 @@ describe("calculator-tab", () => {
|
||||
"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.
|
||||
const ui = mount();
|
||||
expect(ui.getByTestId("calculator-tech-value-drive")).toHaveTextContent(
|
||||
"1.2",
|
||||
);
|
||||
// Stable column-aligned formatting (3 decimals) is what the report
|
||||
// uses, so the tech labels read consistently.
|
||||
const tech = ui.getByTestId("calculator-tech-value-drive");
|
||||
expect(tech.textContent ?? "").toMatch(/^1\.20?0?$/);
|
||||
});
|
||||
|
||||
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" through the shared formatter; the
|
||||
// label is monospaced + right-aligned via the existing `.mat-val`
|
||||
// rule. Formatting check: no stray fractional digits on integers.
|
||||
expect(
|
||||
ui.getByTestId("calculator-planet-mat-value"),
|
||||
).toHaveTextContent("100");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user