b1b87c8521
- 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>
219 lines
6.0 KiB
Svelte
219 lines
6.0 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. 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
|
|
calculator math — so the ship-group upgrade flow can reuse it later.
|
|
-->
|
|
<script lang="ts">
|
|
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>;
|
|
computedInput?: ClaimedInput | null;
|
|
blocksReadonly?: boolean;
|
|
onTechInput: (key: TechKey) => void;
|
|
onResetTech: (key: TechKey) => void;
|
|
};
|
|
let {
|
|
blocks = $bindable(),
|
|
resolved,
|
|
techs = $bindable(),
|
|
techOverridden,
|
|
computedInput = null,
|
|
blocksReadonly = false,
|
|
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 {
|
|
return techs[key] < 0 ? i18n.t("game.calculator.invalid.tech_value") : "";
|
|
}
|
|
|
|
const BLOCK_ROWS: {
|
|
key: keyof DesignBlocksState;
|
|
label: () => string;
|
|
step: string;
|
|
tech: TechKey | null;
|
|
}[] = [
|
|
{ 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" },
|
|
];
|
|
</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"
|
|
type="number"
|
|
step={row.step}
|
|
readonly
|
|
value={resolved[row.key]}
|
|
data-computed="true"
|
|
data-testid={`calculator-block-${row.key}`}
|
|
title={i18n.t("game.calculator.lock.reset")}
|
|
/>
|
|
{:else}
|
|
<input
|
|
class="ship"
|
|
type="number"
|
|
step={row.step}
|
|
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}
|
|
{#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]}
|
|
<button
|
|
type="button"
|
|
class="lock"
|
|
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>
|
|
{/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: #8890b0;
|
|
font-size: 0.7rem;
|
|
text-align: center;
|
|
text-transform: lowercase;
|
|
}
|
|
.label {
|
|
color: #aab;
|
|
font-size: 0.8rem;
|
|
}
|
|
input {
|
|
font: inherit;
|
|
font-size: 0.8rem;
|
|
width: 100%;
|
|
min-width: 0;
|
|
padding: 0.2rem 0.35rem;
|
|
background: #0a0e1a;
|
|
color: #e8eaf6;
|
|
border: 1px solid #2a3150;
|
|
border-radius: 3px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
input[data-computed="true"],
|
|
input[readonly] {
|
|
color: #9fb0ff;
|
|
background: #11162a;
|
|
}
|
|
input[aria-invalid="true"] {
|
|
border-color: #d97a7a;
|
|
}
|
|
.tech-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
}
|
|
.lock {
|
|
flex: none;
|
|
padding: 0;
|
|
font-size: 0.7rem;
|
|
line-height: 1;
|
|
background: transparent;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|