feat(ui-calculator): input validation, load caps, ceil display, modernization layout
- 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:
@@ -8,7 +8,11 @@ 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 { i18n } from "$lib/i18n/index.svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
shipClassFieldErrors,
|
||||
type ShipClassValueInvalidReason,
|
||||
} from "$lib/util/ship-class-validation";
|
||||
import type { ClaimedInput } from "./calc-model";
|
||||
|
||||
export interface DesignBlocksState {
|
||||
@@ -49,6 +53,29 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
onResetTech,
|
||||
}: Props = $props();
|
||||
|
||||
const VALUE_REASON_KEY: Record<ShipClassValueInvalidReason, TranslationKey> = {
|
||||
drive_value: "game.calculator.invalid.drive_value",
|
||||
armament_value: "game.calculator.invalid.armament_value",
|
||||
armament_not_integer: "game.calculator.invalid.armament_not_integer",
|
||||
weapons_value: "game.calculator.invalid.weapons_value",
|
||||
shields_value: "game.calculator.invalid.shields_value",
|
||||
cargo_value: "game.calculator.invalid.cargo_value",
|
||||
armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
|
||||
all_zero: "game.calculator.invalid.all_zero",
|
||||
};
|
||||
|
||||
// Per-block validity (independent of which one failed first) so every
|
||||
// invalid input is highlighted, not only the first.
|
||||
const blockErrors = $derived(shipClassFieldErrors(blocks));
|
||||
|
||||
function blockError(key: keyof DesignBlocksState): string {
|
||||
const reason = blockErrors[key];
|
||||
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 BLOCK_ROWS: {
|
||||
key: keyof DesignBlocksState;
|
||||
label: () => string;
|
||||
@@ -92,6 +119,8 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
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}`}
|
||||
/>
|
||||
{/if}
|
||||
@@ -105,6 +134,8 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
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]}
|
||||
@@ -167,6 +198,9 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
color: #9fb0ff;
|
||||
background: #11162a;
|
||||
}
|
||||
input[aria-invalid="true"] {
|
||||
border-color: #d97a7a;
|
||||
}
|
||||
.tech-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -395,6 +395,10 @@ const en = {
|
||||
"game.calculator.invalid.cargo_value": "cargo must be 0 or ≥ 1",
|
||||
"game.calculator.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
|
||||
"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.load_over_capacity": "load exceeds the ship's cargo capacity",
|
||||
"game.calculator.lock.no_drive": "set a non-zero drive before locking speed",
|
||||
|
||||
"game.table.sciences.title": "sciences",
|
||||
"game.table.sciences.column.name": "name",
|
||||
|
||||
@@ -396,6 +396,10 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.calculator.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
|
||||
"game.calculator.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
|
||||
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
||||
"game.calculator.invalid.negative": "значение не может быть отрицательным",
|
||||
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
|
||||
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
||||
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
||||
|
||||
"game.table.sciences.title": "науки",
|
||||
"game.table.sciences.column.name": "название",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -170,3 +170,35 @@ function isValidDWSC(value: number): boolean {
|
||||
if (!Number.isFinite(value)) return false;
|
||||
return value === 0 || value >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* shipClassFieldErrors returns the invalid reason for each offending
|
||||
* block, independently, so the calculator can mark every bad input
|
||||
* (not just the first failure `validateShipClassValues` reports). The
|
||||
* weapons/armament pairing rule flags both fields. The all-zero rule is
|
||||
* a whole-design condition and is left to `validateShipClassValues`.
|
||||
*/
|
||||
export function shipClassFieldErrors(
|
||||
values: ShipClassValues,
|
||||
): Partial<Record<keyof ShipClassValues, ShipClassValueInvalidReason>> {
|
||||
const errors: Partial<
|
||||
Record<keyof ShipClassValues, ShipClassValueInvalidReason>
|
||||
> = {};
|
||||
if (!isValidDWSC(values.drive)) errors.drive = "drive_value";
|
||||
if (!Number.isFinite(values.armament) || values.armament < 0) {
|
||||
errors.armament = "armament_value";
|
||||
} else if (!Number.isInteger(values.armament)) {
|
||||
errors.armament = "armament_not_integer";
|
||||
}
|
||||
if (!isValidDWSC(values.weapons)) errors.weapons = "weapons_value";
|
||||
if (!isValidDWSC(values.shields)) errors.shields = "shields_value";
|
||||
if (!isValidDWSC(values.cargo)) errors.cargo = "cargo_value";
|
||||
if (
|
||||
(values.armament === 0 && values.weapons !== 0) ||
|
||||
(values.armament !== 0 && values.weapons === 0)
|
||||
) {
|
||||
errors.weapons ??= "armament_weapons_pair";
|
||||
errors.armament ??= "armament_weapons_pair";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -145,6 +145,10 @@ export interface LoadForFullMassInput {
|
||||
cargoTech: number;
|
||||
}
|
||||
|
||||
export interface Ceil3Input {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
/**
|
||||
* signRequest returns the canonical signing input bytes for a v1
|
||||
@@ -311,6 +315,13 @@ export interface Core {
|
||||
* below the empty mass.
|
||||
*/
|
||||
loadForFullMass(input: LoadForFullMassInput): number | null;
|
||||
|
||||
/**
|
||||
* ceil3 wraps `pkg/calc/number.go.Ceil3`: round a value up to three
|
||||
* decimal places, for display so a computed result is never shown
|
||||
* lower than it is.
|
||||
*/
|
||||
ceil3(input: Ceil3Input): number;
|
||||
}
|
||||
|
||||
export type CoreLoader = () => Promise<Core>;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type {
|
||||
BlockUpgradeCostInput,
|
||||
BombingPowerInput,
|
||||
Ceil3Input,
|
||||
CargoCapacityInput,
|
||||
CargoForEmptyMassInput,
|
||||
CarryingMassInput,
|
||||
@@ -73,6 +74,7 @@ interface GalaxyCoreBridge {
|
||||
shieldsForDefence(input: ShieldsForDefenceInput): number | null;
|
||||
cargoForEmptyMass(input: CargoForEmptyMassInput): number | null;
|
||||
loadForFullMass(input: LoadForFullMassInput): number | null;
|
||||
ceil3(input: Ceil3Input): number;
|
||||
}
|
||||
|
||||
interface BridgeRequestFields {
|
||||
@@ -266,6 +268,9 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
|
||||
loadForFullMass(input: LoadForFullMassInput): number | null {
|
||||
return bridge.loadForFullMass(input);
|
||||
},
|
||||
ceil3(input: Ceil3Input): number {
|
||||
return bridge.ceil3(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user