feat(ui-calculator): input validation, load caps, ceil display, modernization layout
Tests · Go / test (push) Successful in 2m26s
Tests · UI / test (push) Successful in 2m26s

- custom load capped at cargo capacity (error when exceeded); full load shows the cargo capacity; zero cargo pins load to empty and disables the toggle

- per-input red border + tooltip for every invalid value (blocks, techs, load, MAT, modernization target); no value may be negative; locking a speed is disabled when drive is zero

- display every computed number (results + goal-seek back-solved input) rounded up to 3 decimals via a shared pkg/calc Ceil3 bridged to wasm; engine keeps its own round-to-nearest util.Fixed*

- modernization total upgrade cost spans two columns (single line)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 21:24:40 +02:00
parent 3ea29cf8b5
commit b1b87c8521
17 changed files with 343 additions and 9 deletions
@@ -140,6 +140,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
}
});
// With no cargo block there is no hold to load: pin the load to empty
// and disable the toggle.
const cargoEmpty = $derived(cs.blocks.cargo === 0);
$effect(() => {
if (cargoEmpty && cs.loadMode !== "empty") cs.loadMode = "empty";
});
const planetBuild = $derived.by(() => {
if (selectedPlanet === null) return null;
const emptyMass = result.outputs?.emptyMass;
@@ -228,11 +235,54 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
return { perBlock, total };
});
// Display every computed number rounded up to three decimals via the
// shared `Ceil3` bridge, so a value is never shown lower than it is.
function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable");
}
return value.toLocaleString(undefined, { maximumFractionDigits: 3 });
const rounded = core !== null ? core.ceil3({ value }) : value;
return rounded.toLocaleString(undefined, { maximumFractionDigits: 3 });
}
// The goal-seek back-solved block, shown in its read-only cell, is
// ceiled the same way (only the claimed block's cell is displayed).
const resolvedCeil = $derived.by(() => {
if (core === null) return result.blocks;
const c = (v: number) => core.ceil3({ value: v });
return {
drive: c(result.blocks.drive),
armament: result.blocks.armament,
weapons: c(result.blocks.weapons),
shields: c(result.blocks.shields),
cargo: c(result.blocks.cargo),
};
});
// A custom load must stay within [0, cargo capacity]; beyond that the
// ship cannot hold it.
const customLoadError = $derived.by(() => {
if (cs.loadMode !== "custom") return "";
if (cs.customLoad < 0) return i18n.t("game.calculator.invalid.negative");
if (cs.customLoad > result.cargoCapacity) {
return i18n.t("game.calculator.invalid.load_over_capacity");
}
return "";
});
const matError = $derived(
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
);
// Locking a speed back-solves the drive block; with a zero drive the
// ship is deliberately immobile, so disallow it.
function lockDisabledReason(output: LockableOutputId): string {
if (
(output === "speedEmpty" || output === "speedLoaded") &&
cs.blocks.drive === 0
) {
return i18n.t("game.calculator.lock.no_drive");
}
return "";
}
function onTechInput(key: TechKey): void {
@@ -346,15 +396,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
</button>
</span>
{:else}
{@const extra = lockDisabledReason(output)}
<span class="cell">
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
<button
type="button"
class="lock"
disabled={cs.lock !== null || value === undefined}
disabled={cs.lock !== null || value === undefined || extra !== ""}
title={cs.lock !== null
? i18n.t("game.calculator.lock.max")
: `${LOCK_LABELS[output]}`}
: extra !== ""
? extra
: LOCK_LABELS[output]}
aria-label={LOCK_LABELS[output]}
data-testid={`calculator-lock-${output}`}
onclick={() => lockOutput(output)}
@@ -429,7 +482,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<ShipDesignArea
bind:blocks={cs.blocks}
resolved={result.blocks}
resolved={resolvedCeil}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
computedInput={result.computedInput}
@@ -445,6 +498,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<button
type="button"
class:active={cs.loadMode === m}
disabled={cargoEmpty}
data-testid={`calculator-load-${m}`}
onclick={() => (cs.loadMode = m)}
>
@@ -459,8 +513,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
min="0"
class="custom-load"
bind:value={cs.customLoad}
aria-invalid={customLoadError !== "" ? "true" : "false"}
title={customLoadError}
data-testid="calculator-custom-load"
/>
{:else if cs.loadMode === "full"}
<span
class="full-capacity"
title={i18n.t("game.calculator.out.cargo_capacity")}
data-testid="calculator-full-capacity"
>
{fmt(result.cargoCapacity)}
</span>
{/if}
</div>
@@ -531,6 +595,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
min="0"
bind:value={cs.matValue}
oninput={onMatInput}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
{#if cs.matOverridden}
@@ -580,6 +646,10 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
step="0.001"
min="0"
bind:value={cs.targetTech[row.key]}
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
title={cs.targetTech[row.key] < 0
? i18n.t("game.calculator.invalid.negative")
: ""}
data-testid={`calculator-target-${row.key}`}
/>
</span>
@@ -592,7 +662,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
{/each}
<div class="rrow total">
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
<span></span>
<span class="cell">
<span class="val" data-testid="calculator-modern-total">
{fmt(modernCosts?.total)}
@@ -814,6 +883,20 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
text-align: right;
}
.rrow.total .label {
grid-column: 1 / 3;
color: #cdd3f0;
white-space: nowrap;
}
input[aria-invalid="true"] {
border-color: #d97a7a;
}
.seg button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.full-capacity {
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
color: #9fb0ff;
}
</style>