b6770d394c
Collapse the game UI to one route (`/`): a screen dispatcher renders login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of URL routes. Move screen components to lib/screens & lib/game; the game shell reads the game id from `appScreen.gameId` and re-inits per-game stores via an $effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now state-based. WIP: browser-history (Back→lobby), restore-validation, the return-to-lobby button, push deep-links, and the test migration are follow-ups on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
903 lines
25 KiB
Svelte
903 lines
25 KiB
Svelte
<!--
|
||
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 } 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 1–3 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);
|
||
const canDelete = $derived(
|
||
cs.loadedExisting !== null &&
|
||
existingNames.includes(cs.loadedExisting) &&
|
||
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.
|
||
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, { 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 {
|
||
cs.techOverridden[key] = true;
|
||
}
|
||
function onResetTech(key: TechKey): void {
|
||
cs.techOverridden[key] = false;
|
||
}
|
||
function onMatInput(): void {
|
||
cs.matOverridden = true;
|
||
}
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
async function deleteClass(): Promise<void> {
|
||
if (cs.loadedExisting === null || draft === undefined) return;
|
||
await draft.add({
|
||
kind: "removeShipClass",
|
||
id: crypto.randomUUID(),
|
||
name: cs.loadedExisting,
|
||
});
|
||
cs.loadedExisting = null;
|
||
}
|
||
|
||
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
|
||
type="number"
|
||
step="0.001"
|
||
bind:value={cs.lockValue}
|
||
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={() => (cs.loadedExisting = null)}
|
||
onchange={() => loadExisting(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>
|
||
{#if cs.mode === "ship" && canDelete}
|
||
<button
|
||
type="button"
|
||
class="delete"
|
||
data-testid="calculator-delete"
|
||
onclick={() => void deleteClass()}
|
||
>
|
||
{i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
|
||
</button>
|
||
{/if}
|
||
|
||
<ShipDesignArea
|
||
bind:blocks={cs.blocks}
|
||
resolved={resolvedCeil}
|
||
bind:techs={cs.techValues}
|
||
techOverridden={cs.techOverridden}
|
||
computedInput={result.computedInput}
|
||
{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="0.01"
|
||
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>
|
||
|
||
<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>
|
||
<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>
|
||
<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">
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
bind:value={cs.matValue}
|
||
oninput={onMatInput}
|
||
aria-invalid={matError !== "" ? "true" : "false"}
|
||
title={matError}
|
||
data-testid="calculator-planet-mat"
|
||
/>
|
||
{#if cs.matOverridden}
|
||
<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>
|
||
{/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)}
|
||
<div class="rrow">
|
||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||
<span class="cell">
|
||
<input
|
||
type="number"
|
||
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>
|
||
<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,
|
||
.delete {
|
||
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;
|
||
}
|
||
.delete {
|
||
color: var(--color-danger);
|
||
align-self: flex-start;
|
||
}
|
||
.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: inherit;
|
||
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;
|
||
}
|
||
.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-variant-numeric: tabular-nums;
|
||
font-size: 0.85rem;
|
||
text-align: right;
|
||
}
|
||
.cell input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
font: inherit;
|
||
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;
|
||
}
|
||
.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;
|
||
}
|
||
.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-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-variant-numeric: tabular-nums;
|
||
font-size: 0.8rem;
|
||
color: var(--color-accent);
|
||
}
|
||
</style>
|