Files
galaxy-game/ui/frontend/src/lib/sidebar/calculator-tab.svelte
T
Ilia Denisov b01a60e42b
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m34s
fix(ui): F8-06 calculator polish — drop delete-class button, reserve lock slot
- Remove the `delete <ship_class_name>` button (and `deleteClass`,
  `canDelete`, `.delete` CSS, `game.calculator.action.delete` i18n key)
  from the calculator. Delete-class lives in the ship-classes table —
  the broader rework will land under #53.
- Bombing and cargo-capacity rows now reserve a hidden lock-slot
  placeholder so their value column lines up vertically with the
  mass/speed/attack/defence rows (which carry a lock button).
2026-05-26 19:10:59 +02:00

1086 lines
32 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Phase 30 ship-class calculator. Replaces the Phase 17/18 standalone
designer: it fuses the ship design blocks, the live derived results
(mass, speed, attack, defence, bombing), and a planet build-rate readout
into one sidebar tool, and adds single-target goal-seek — the player pins
one result and the model back-solves the single input it claims (see
`lib/calculator/calc-model.ts`). A second mode reuses the design area to
price ship-class modernization. All math comes from `pkg/calc` through
the `Core` WASM bridge; this component only renders + orchestrates.
Input state lives in the long-lived `calculatorState` singleton, not in
the component, so it survives the sidebar unmounting this tab on a tab
switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
import { getContext, tick } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import type { ReportPlanet, ShipClassSummary } from "../../api/game-state";
import {
validateShipClass,
type ShipClassInvalidReason,
} from "$lib/util/ship-class-validation";
import {
computeCalculator,
computePlanetBuild,
type LockableOutputId,
type LoadMode,
} from "$lib/calculator/calc-model";
import ShipDesignArea, {
type TechKey,
} from "$lib/calculator/ship-design-area.svelte";
import { reachStore } from "$lib/calculator/reach.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
import { calculatorState } from "$lib/calculator/calc-state.svelte";
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
// The long-lived input state (survives tab unmount/remount).
const cs = calculatorState;
// Reset the design when the active game changes; a no-op otherwise, so
// the design persists across tab switches within a game.
$effect(() => {
cs.ensureGame(appScreen.gameId ?? "");
});
const core = $derived(coreHandle?.core ?? null);
const report = $derived(rendered?.report ?? null);
const localShipClass = $derived<ShipClassSummary[]>(
report?.localShipClass ?? [],
);
const existingNames = $derived(localShipClass.map((c) => c.name));
const playerTech = $derived({
drive: report?.localPlayerDrive ?? 0,
weapons: report?.localPlayerWeapons ?? 0,
shields: report?.localPlayerShields ?? 0,
cargo: report?.localPlayerCargo ?? 0,
});
const techKeys: TechKey[] = ["drive", "weapons", "shields", "cargo"];
// Non-overridden tech levels track the player's current tech; the
// effect resets them whenever the report (history snapshot included)
// changes, so the calculator reflects the right turn's tech.
$effect(() => {
for (const k of techKeys) {
if (!cs.techOverridden[k]) cs.techValues[k] = playerTech[k];
}
});
// Seed the modernization target with the player's current tech once
// the report has loaded; afterwards it is the player's to edit.
$effect(() => {
if (cs.targetSeeded) return;
if (
playerTech.drive ||
playerTech.weapons ||
playerTech.shields ||
playerTech.cargo
) {
cs.targetTech = { ...playerTech };
cs.targetSeeded = true;
}
});
const result = $derived(
computeCalculator(
{
blocks: cs.blocks,
driveTech: cs.techValues.drive,
weaponsTech: cs.techValues.weapons,
shieldsTech: cs.techValues.shields,
cargoTech: cs.techValues.cargo,
loadMode: cs.loadMode,
customLoad: cs.customLoad,
lock: cs.lock === null ? null : { output: cs.lock, value: cs.lockValue },
},
core,
),
);
// Selected own planet (MVP: own planets only).
const selectedPlanet = $derived.by<ReportPlanet | null>(() => {
const sel = selection?.selected;
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
const planet = report?.planets.find((p) => p.number === sel.id) ?? null;
if (planet === null || planet.kind !== "local") return null;
return planet;
});
$effect(() => {
if (!cs.matOverridden) {
cs.matValue = selectedPlanet?.materialsStockpile ?? 0;
}
});
// 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;
if (emptyMass === undefined) return null;
return computePlanetBuild(
{
shipMass: emptyMass,
freeIndustry: selectedPlanet.freeIndustry ?? 0,
material: cs.matValue,
resources: selectedPlanet.resources ?? 0,
},
core,
);
});
// Publish the selected planet's reach (loaded speed) so the map view
// can draw 13 reach circles. Cleared when the design is invalid, no
// own planet is selected, or the calculator is in modernization mode.
$effect(() => {
const out = result.outputs;
if (cs.mode === "ship" && selectedPlanet !== null && out !== null) {
reachStore.set(
{ x: selectedPlanet.x, y: selectedPlanet.y },
out.speedLoaded,
);
} else {
reachStore.clear();
}
return () => reachStore.clear();
});
const nameInvalidKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
empty: "game.calculator.invalid.empty",
too_long: "game.calculator.invalid.too_long",
starts_with_special: "game.calculator.invalid.starts_with_special",
ends_with_special: "game.calculator.invalid.ends_with_special",
consecutive_specials: "game.calculator.invalid.consecutive_specials",
whitespace: "game.calculator.invalid.whitespace",
disallowed_character: "game.calculator.invalid.disallowed_character",
duplicate_name: "game.calculator.invalid.duplicate_name",
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",
};
const nameValidation = $derived(
validateShipClass({ name: cs.name, ...result.blocks }, { existingNames }),
);
const createMessage = $derived(
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
);
const canCreate = $derived(nameValidation.ok && draft !== undefined);
// Per-block modernization upgrade cost (current tech → target tech).
const modernCosts = $derived.by(() => {
if (core === null) return null;
const weaponsMass = core.weaponsBlockMass({
weapons: cs.blocks.weapons,
armament: cs.blocks.armament,
});
const rows: { key: TechKey; mass: number }[] = [
{ key: "drive", mass: cs.blocks.drive },
{ key: "weapons", mass: weaponsMass ?? 0 },
{ key: "shields", mass: cs.blocks.shields },
{ key: "cargo", mass: cs.blocks.cargo },
];
const perBlock = rows.map((r) => ({
key: r.key,
cost: core.blockUpgradeCost({
blockMass: r.mass,
currentTech: cs.techValues[r.key],
targetTech: cs.targetTech[r.key],
}),
}));
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
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.
// Always three decimals (`1` → `1.000`) for column-aligned readability,
// and without thousands grouping so the same string also embeds in the
// read-only goal-seek `<input type="number">` cell.
function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable");
}
const rounded = core !== null ? core.ceil3({ value }) : value;
return rounded.toLocaleString(undefined, {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
useGrouping: false,
});
}
// Cap typed precision at three decimal digits. Number inputs use
// `step="any"`, which lets the browser accept arbitrary precision; the
// owner asked us to refuse a fourth decimal as typing happens so the
// calculator never displays a longer-than-three-digit fraction. Pairs
// with `bind:value`: if Svelte's bind handler has already read the
// over-precise number, `apply` overwrites the state with the truncated
// value so the next reactive flush does not undo our truncation.
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);
}
// 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") : "",
);
// Modernization target tech mirrors the design-area floor: a target
// below the player's current tech on this turn is meaningless (no
// upgrade), so flag it the same way.
function targetTechError(key: TechKey): string {
const value = cs.targetTech[key];
if (value < 0) return i18n.t("game.calculator.invalid.negative");
if (value < playerTech[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// 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 {
cs.techOverridden[key] = true;
}
function onResetTech(key: TechKey): void {
cs.techOverridden[key] = false;
}
const matInputRef: { el?: HTMLInputElement } = {};
async function activateMatOverride(): Promise<void> {
cs.matOverridden = true;
await tick();
matInputRef.el?.focus();
matInputRef.el?.select();
}
function resetMat(): void {
cs.matOverridden = false;
}
function lockOutput(output: LockableOutputId): void {
if (cs.lock !== null) return;
cs.lockValue = result.outputs?.[output] ?? 0;
cs.lock = output;
}
function unlock(): void {
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;
cs.blocks = {
drive: cls.drive,
armament: cls.armament,
weapons: cls.weapons,
shields: cls.shields,
cargo: cls.cargo,
};
cs.name = cls.name;
cs.loadedExisting = cls.name;
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.
$effect(() => {
const token = calculatorLoadRequest.token;
if (token === cs.handledLoadToken) return;
cs.handledLoadToken = token;
cs.mode = "ship";
if (calculatorLoadRequest.name === null) cs.resetDesign();
else loadExisting(calculatorLoadRequest.name);
});
async function create(): Promise<void> {
if (!nameValidation.ok || draft === undefined) return;
// Capture the validated draft before awaiting: adding the command
// re-projects `localShipClass`, which re-runs the `nameValidation`
// derived into a `duplicate_name` failure (the class now exists),
// leaving `nameValidation.value` undefined after the await.
const created = nameValidation.value;
await draft.add({
kind: "createShipClass",
id: crypto.randomUUID(),
name: created.name,
drive: created.drive,
armament: created.armament,
weapons: created.weapons,
shields: created.shields,
cargo: created.cargo,
});
cs.loadedExisting = created.name;
}
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
emptyMass: i18n.t("game.calculator.out.mass"),
loadedMass: i18n.t("game.calculator.out.mass"),
speedEmpty: i18n.t("game.calculator.out.speed"),
speedLoaded: i18n.t("game.calculator.out.speed"),
attack: i18n.t("game.calculator.out.attack"),
defense: i18n.t("game.calculator.out.defense"),
});
</script>
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
{#if cs.lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}>
<input
class="no-spin"
type="number"
step="any"
bind:value={cs.lockValue}
onkeydown={(e) =>
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
oninput={(e) => capDecimals(e, (v) => (cs.lockValue = v))}
data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/>
<button
type="button"
class="lock active"
title={i18n.t("game.calculator.lock.reset")}
aria-label={i18n.t("game.calculator.lock.reset")}
data-testid={`calculator-unlock-${output}`}
onclick={unlock}
>
🔒
</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 || extra !== ""}
title={cs.lock !== null
? i18n.t("game.calculator.lock.max")
: extra !== ""
? extra
: LOCK_LABELS[output]}
aria-label={LOCK_LABELS[output]}
data-testid={`calculator-lock-${output}`}
onclick={() => lockOutput(output)}
>
🔓
</button>
</span>
{/if}
{/snippet}
<section class="calculator" data-testid="sidebar-tool-calculator">
<div class="modes" role="tablist">
<button
type="button"
class:active={cs.mode === "ship"}
data-testid="calculator-mode-ship"
onclick={() => (cs.mode = "ship")}
>
{i18n.t("game.calculator.mode.ship")}
</button>
<button
type="button"
class:active={cs.mode === "modernization"}
data-testid="calculator-mode-modernization"
onclick={() => (cs.mode = "modernization")}
>
{i18n.t("game.calculator.mode.modernization")}
</button>
</div>
<div class="namebar">
<input
type="text"
class="name"
list="calculator-existing-classes"
placeholder={i18n.t("game.calculator.name.placeholder")}
maxlength="30"
bind:value={cs.name}
oninput={onNameInput}
onchange={() => tryLoadByName(cs.name)}
aria-invalid={nameValidation.ok ? "false" : "true"}
data-testid="calculator-name"
/>
<datalist id="calculator-existing-classes">
{#each localShipClass as cls (cls.name)}
<option value={cls.name}></option>
{/each}
</datalist>
{#if cs.mode === "ship"}
<button
type="button"
class="create"
disabled={!canCreate}
title={canCreate ? "" : createMessage}
data-testid="calculator-create"
onclick={() => void create()}
>
{i18n.t("game.calculator.action.create")}
</button>
{/if}
</div>
<ShipDesignArea
bind:blocks={cs.blocks}
resolved={resolvedCeil}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
techFloor={playerTech}
computedInput={result.computedInput}
formatNumber={fmt}
{onTechInput}
{onResetTech}
/>
{#if cs.mode === "ship"}
<div class="load">
<span class="label">{i18n.t("game.calculator.load.label")}</span>
<div class="seg" role="group">
{#each LOAD_MODES as m (m)}
<button
type="button"
class:active={cs.loadMode === m}
disabled={cargoEmpty}
data-testid={`calculator-load-${m}`}
onclick={() => (cs.loadMode = m)}
>
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
</button>
{/each}
</div>
{#if cs.loadMode === "custom"}
<input
type="number"
step="any"
min="0"
class="custom-load no-spin"
bind:value={cs.customLoad}
onkeydown={(e) =>
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
oninput={(e) => capDecimals(e, (v) => (cs.customLoad = v))}
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>
<div class="results" data-testid="calculator-results">
<div class="rrow head">
<span></span>
<span class="col-head">{i18n.t("game.calculator.col.empty")}</span>
<span class="col-head">{i18n.t("game.calculator.col.loaded")}</span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.mass")}</span>
{@render lockable("emptyMass", result.outputs?.emptyMass)}
{@render lockable("loadedMass", result.outputs?.loadedMass)}
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.speed")}</span>
{@render lockable("speedEmpty", result.outputs?.speedEmpty)}
{@render lockable("speedLoaded", result.outputs?.speedLoaded)}
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.attack")}</span>
{@render lockable("attack", result.outputs?.attack)}
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.defense")}</span>
{@render lockable("defense", result.outputs?.defense)}
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.bombing")}</span>
<span class="cell">
<span class="val" data-testid="calculator-out-bombing">
{fmt(result.outputs?.bombing)}
</span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span>
<span></span>
</div>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.out.cargo_capacity")}</span>
<span class="cell">
<span class="val" data-testid="calculator-out-cargo-capacity">
{fmt(result.outputs === null ? null : result.cargoCapacity)}
</span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span>
<span></span>
</div>
</div>
<div class="planet" data-testid="calculator-planet-area">
{#if selectedPlanet === null}
<p class="hint" data-testid="calculator-planet-none">
{i18n.t("game.calculator.planet.none")}
</p>
{:else}
<p class="planet-name" data-testid="calculator-planet-name">
{i18n.t("game.calculator.planet.label", {
name: selectedPlanet.name,
number: String(selectedPlanet.number),
})}
</p>
<div class="rrow">
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
<span class="cell">
{#if cs.matOverridden}
<input
bind:this={matInputRef.el}
class="no-spin"
type="number"
step="any"
min="0"
bind:value={cs.matValue}
onkeydown={(e) =>
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
oninput={(e) => capDecimals(e, (v) => (cs.matValue = v))}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
<button
type="button"
class="lock active"
title={i18n.t("game.calculator.mat.reset")}
aria-label={i18n.t("game.calculator.mat.reset")}
data-testid="calculator-mat-reset"
onclick={resetMat}
>
🔒
</button>
{:else}
<span
class="mat-val"
data-testid="calculator-planet-mat-value"
>
{fmt(cs.matValue)}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.mat.override")}
aria-label={i18n.t("game.calculator.mat.override")}
data-testid="calculator-mat-override"
onclick={() => void activateMatOverride()}
>
🔓
</button>
{/if}
</span>
<span></span>
</div>
<dl class="planet-stats">
<div>
<dt>{i18n.t("game.calculator.planet.ships_per_turn")}</dt>
<dd data-testid="calculator-ships-per-turn">
{fmt(planetBuild?.shipsPerTurn)}
</dd>
</div>
<div>
<dt>{i18n.t("game.calculator.planet.turns_per_ship")}</dt>
<dd data-testid="calculator-turns-per-ship">
{fmt(planetBuild?.turnsPerShip ?? null)}
</dd>
</div>
</dl>
{/if}
</div>
{:else}
<div class="modern" data-testid="calculator-modernization">
<div class="rrow head">
<span></span>
<span class="col-head">{i18n.t("game.calculator.modern.target")}</span>
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div>
{#each modernCosts?.perBlock ?? [] as row (row.key)}
{@const targetError = targetTechError(row.key)}
<div class="rrow">
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
<span class="cell">
<input
class="no-spin"
type="number"
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),
)}
oninput={(e) =>
capDecimals(e, (v) => (cs.targetTech[row.key] = v))}
aria-invalid={targetError !== "" ? "true" : "false"}
title={targetError}
data-testid={`calculator-target-${row.key}`}
/>
</span>
<span class="cell">
<span class="val" data-testid={`calculator-modern-cost-${row.key}`}>
{fmt(row.cost)}
</span>
</span>
</div>
{/each}
<div class="rrow total">
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
<span class="cell">
<span class="val" data-testid="calculator-modern-total">
{fmt(modernCosts?.total)}
</span>
</span>
</div>
</div>
{/if}
</section>
<style>
.calculator {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem;
font-family: system-ui, sans-serif;
}
.modes {
display: flex;
gap: 0.25rem;
}
.modes button {
flex: 1;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.modes button.active {
color: var(--color-text);
border-color: var(--color-accent);
background: var(--color-surface-raised);
}
.namebar {
display: flex;
gap: 0.35rem;
}
.name {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.4rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.name[aria-invalid="true"] {
border-color: var(--color-danger);
}
.create {
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.create:not(:disabled):hover {
color: var(--color-text);
border-color: var(--color-accent);
}
.create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.load {
display: flex;
align-items: center;
gap: 0.4rem;
}
.seg {
display: flex;
gap: 0.15rem;
}
.seg button {
font: inherit;
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.seg button.active {
color: var(--color-text);
border-color: var(--color-accent);
}
.custom-load {
width: 4rem;
font-family: var(--font-mono);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
text-align: right;
}
.results,
.modern {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rrow {
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;
}
.label {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.cell {
display: flex;
align-items: center;
gap: 0.2rem;
justify-content: flex-end;
}
.cell .val {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.cell input {
width: 100%;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
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 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);
}
.cell.infeasible input {
border-color: var(--color-danger);
color: var(--color-danger);
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:not(:disabled):hover {
opacity: 1;
}
.lock:disabled {
cursor: not-allowed;
opacity: 0.2;
}
.lock-slot {
flex: none;
font-size: 0.7rem;
line-height: 1;
visibility: hidden;
}
.planet {
border-top: 1px solid var(--color-border-subtle);
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hint {
margin: 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.planet-name {
margin: 0;
font-size: 0.8rem;
color: var(--color-text);
}
.planet-stats {
margin: 0;
display: grid;
grid-template-columns: 1fr max-content;
row-gap: 0.2rem;
column-gap: 0.5rem;
}
.planet-stats div {
display: contents;
}
.planet-stats dt {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.planet-stats dd {
margin: 0;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
}
.rrow.total .label {
grid-column: 1 / 3;
color: var(--color-text);
white-space: nowrap;
}
input[aria-invalid="true"] {
border-color: var(--color-danger);
}
.seg button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.full-capacity {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
color: var(--color-accent);
}
/* Plain-text view of the planet MAT (mirrors `.tech-val` in the
design area) so the cell width stays the same whether the value is
the inherited planet number or the player's override. */
.mat-val {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.15rem 0.3rem;
}
</style>