cc4727a32e
Owner feedback round 2 on PR #61: - Pad every read-only calculator value to three decimals: tech labels, derived results (mass, speed, attack, defence, bombing, cargo capacity), planet MAT, planet build-rate, modernization cost, and the full-cargo capacity label all read as "1.000" instead of "1", matching the goal-seek back-solved input and the report. Drops thousands grouping so the same `fmt()` string also embeds cleanly in the read-only `<input type="number">` cell. - Switch label and input styling onto the existing `--font-mono` token (right-aligned, tabular-nums) so columns line up vertically across rows like a financial table. - Refuse a fourth decimal as the user types in every calculator number input (DWSC blocks, tech, MAT, custom load, lock value, modernization target tech): the `oninput` truncates the input text past three decimal digits and explicitly writes the truncated value back through `bind:value`, so Svelte's later reactive flush cannot undo the cap. - Doc + tests follow the rule (five new vitest cases covering the 3-decimal label format, the input cap on each input class, and the integer-padding rule for derived results).
354 lines
11 KiB
Svelte
354 lines
11 KiB
Svelte
<!--
|
||
Reusable "Ship Class design area": the five design blocks (drive,
|
||
armament, weapons, shields, cargo) plus the four tech levels they are
|
||
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,
|
||
type ShipClassValueInvalidReason,
|
||
} from "$lib/util/ship-class-validation";
|
||
import type { ClaimedInput } from "./calc-model";
|
||
|
||
export interface DesignBlocksState {
|
||
drive: number;
|
||
armament: number;
|
||
weapons: number;
|
||
shields: number;
|
||
cargo: number;
|
||
}
|
||
export interface TechState {
|
||
drive: number;
|
||
weapons: number;
|
||
shields: number;
|
||
cargo: number;
|
||
}
|
||
export type TechKey = keyof TechState;
|
||
|
||
type Props = {
|
||
blocks: DesignBlocksState;
|
||
// Blocks after goal-seek: the claimed block carries its solved
|
||
// value, which is what the read-only computed cell displays.
|
||
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;
|
||
// 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;
|
||
};
|
||
let {
|
||
blocks = $bindable(),
|
||
resolved,
|
||
techs = $bindable(),
|
||
techOverridden,
|
||
techFloor,
|
||
computedInput = null,
|
||
blocksReadonly = false,
|
||
formatNumber,
|
||
onTechInput,
|
||
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 {
|
||
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: {
|
||
key: keyof DesignBlocksState;
|
||
label: () => string;
|
||
tech: TechKey | null;
|
||
smartStep: boolean;
|
||
}[] = [
|
||
{ 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>> = {};
|
||
|
||
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">
|
||
<div class="cols">
|
||
<span></span>
|
||
<span class="col-head">{i18n.t("game.calculator.col.ship")}</span>
|
||
<span class="col-head">{i18n.t("game.calculator.col.tech")}</span>
|
||
</div>
|
||
{#each BLOCK_ROWS as row (row.key)}
|
||
{@const isComputed = computedInput === row.key}
|
||
<div class="row">
|
||
<span class="label">{row.label()}</span>
|
||
{#if isComputed}
|
||
<input
|
||
class="ship no-spin"
|
||
type="number"
|
||
step="any"
|
||
readonly
|
||
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 no-spin"
|
||
type="number"
|
||
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={(e) => onBlockKey(e, row.key, row.smartStep)}
|
||
oninput={(e) => capDecimals(e, (v) => (blocks[row.key] = v))}
|
||
/>
|
||
{/if}
|
||
{#if row.tech !== null}
|
||
{@const techKey = row.tech}
|
||
<span class="tech-cell">
|
||
{#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
|
||
type="button"
|
||
class="lock active"
|
||
title={i18n.t("game.calculator.tech.reset")}
|
||
aria-label={i18n.t("game.calculator.tech.reset")}
|
||
data-testid={`calculator-tech-reset-${techKey}`}
|
||
onclick={() => onResetTech(techKey)}
|
||
>
|
||
🔒
|
||
</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}
|
||
</span>
|
||
{:else}
|
||
<span></span>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<style>
|
||
.design {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
}
|
||
.cols,
|
||
.row {
|
||
display: grid;
|
||
grid-template-columns: 4.5rem 1fr 1fr;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
}
|
||
.col-head {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.7rem;
|
||
text-align: center;
|
||
text-transform: lowercase;
|
||
}
|
||
.label {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.8rem;
|
||
}
|
||
input {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.8rem;
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 0.2rem 0.35rem;
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 3px;
|
||
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[readonly] {
|
||
color: var(--color-accent);
|
||
background: var(--color-surface-raised);
|
||
}
|
||
input[aria-invalid="true"] {
|
||
border-color: var(--color-danger);
|
||
}
|
||
.tech-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
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 {
|
||
flex: none;
|
||
padding: 0;
|
||
font-size: 0.7rem;
|
||
line-height: 1;
|
||
background: transparent;
|
||
border: 0;
|
||
cursor: pointer;
|
||
opacity: 0.5;
|
||
}
|
||
.lock.active,
|
||
.lock:hover {
|
||
opacity: 1;
|
||
}
|
||
</style>
|